mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'main' of https://github.com/Infisical/infisical into feat/suborg-scope-support
This commit is contained in:
@@ -67,6 +67,7 @@ const Content = ({ secretRotation }: ContentProps) => {
|
||||
case SecretRotation.MySqlCredentials:
|
||||
case SecretRotation.MsSqlCredentials:
|
||||
case SecretRotation.OracleDBCredentials:
|
||||
case SecretRotation.MongoDBCredentials:
|
||||
Component = (
|
||||
<ViewSqlCredentialsRotationGeneratedCredentials
|
||||
generatedCredentialsResponse={generatedCredentialsResponse}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
|
||||
import { ViewRotationGeneratedCredentialsDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewRotationGeneratedCredentialsDisplay";
|
||||
import { TMongoDBCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/mongodb-credentials-rotation";
|
||||
import { TMsSqlCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/mssql-credentials-rotation";
|
||||
import { TMySqlCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/mysql-credentials-rotation";
|
||||
import { TOracleDBCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/oracledb-credentials-rotation";
|
||||
@@ -10,7 +11,8 @@ type Props = {
|
||||
| TMsSqlCredentialsRotationGeneratedCredentialsResponse
|
||||
| TMySqlCredentialsRotationGeneratedCredentialsResponse
|
||||
| TOracleDBCredentialsRotationGeneratedCredentialsResponse
|
||||
| TPostgresCredentialsRotationGeneratedCredentialsResponse;
|
||||
| TPostgresCredentialsRotationGeneratedCredentialsResponse
|
||||
| TMongoDBCredentialsRotationGeneratedCredentialsResponse;
|
||||
};
|
||||
|
||||
export const ViewSqlCredentialsRotationGeneratedCredentials = ({
|
||||
|
||||
@@ -21,7 +21,8 @@ const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
|
||||
[SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields,
|
||||
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields,
|
||||
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields,
|
||||
[SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields
|
||||
[SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields,
|
||||
[SecretRotation.MongoDBCredentials]: SqlCredentialsRotationParametersFields
|
||||
};
|
||||
|
||||
export const SecretRotationV2ParametersFields = () => {
|
||||
|
||||
@@ -24,7 +24,8 @@ const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
|
||||
[SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields,
|
||||
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields,
|
||||
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields,
|
||||
[SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields
|
||||
[SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields,
|
||||
[SecretRotation.MongoDBCredentials]: SqlCredentialsRotationReviewFields
|
||||
};
|
||||
|
||||
export const SecretRotationV2ReviewFields = () => {
|
||||
|
||||
@@ -21,7 +21,8 @@ const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
|
||||
[SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields,
|
||||
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields,
|
||||
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields,
|
||||
[SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields
|
||||
[SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields,
|
||||
[SecretRotation.MongoDBCredentials]: SqlCredentialsRotationSecretsMappingFields
|
||||
};
|
||||
|
||||
export const SecretRotationV2SecretsMappingFields = () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotation
|
||||
import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema";
|
||||
import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema";
|
||||
import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema";
|
||||
import { MongoDBCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mongodb-credentials-rotation-schema";
|
||||
import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
|
||||
import { MySqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema";
|
||||
import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
|
||||
@@ -27,7 +28,8 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
|
||||
LdapPasswordRotationSchema,
|
||||
AwsIamUserSecretRotationSchema,
|
||||
OktaClientSecretRotationSchema,
|
||||
RedisCredentialsRotationSchema
|
||||
RedisCredentialsRotationSchema,
|
||||
MongoDBCredentialsRotationSchema
|
||||
]),
|
||||
z.object({ id: z.string().optional() })
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
|
||||
import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
|
||||
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
|
||||
|
||||
export const MongoDBCredentialsRotationSchema = z
|
||||
.object({
|
||||
type: z.literal(SecretRotation.MongoDBCredentials)
|
||||
})
|
||||
.merge(SqlCredentialsRotationSchema)
|
||||
.merge(BaseSecretRotationSchema);
|
||||
@@ -65,6 +65,12 @@ const inputParentContainerVariants = cva("inline-flex font-inter items-center bo
|
||||
}
|
||||
});
|
||||
|
||||
const data1pIgnore = (autoComplete?: string) => {
|
||||
if (!autoComplete) return true;
|
||||
|
||||
return !autoComplete.match(/(email|password|username)/i);
|
||||
};
|
||||
|
||||
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> &
|
||||
VariantProps<typeof inputVariants> &
|
||||
Props;
|
||||
@@ -86,6 +92,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
isReadOnly,
|
||||
autoCapitalization,
|
||||
warning,
|
||||
autoComplete,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -116,6 +123,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
readOnly={isReadOnly}
|
||||
disabled={isDisabled}
|
||||
onInput={handleInput}
|
||||
autoComplete={autoComplete}
|
||||
data-1p-ignore={data1pIgnore(autoComplete)}
|
||||
className={twMerge(
|
||||
leftIcon ? "pl-10" : "pl-2.5",
|
||||
rightIcon || warning ? "pr-10" : "pr-2.5",
|
||||
|
||||
@@ -359,6 +359,10 @@ export const ROUTE_PATHS = Object.freeze({
|
||||
"/organizations/$orgId/projects/pam/$projectId/sessions",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/"
|
||||
),
|
||||
ApprovalRequestDetailPage: setRoute(
|
||||
"/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId"
|
||||
),
|
||||
PamSessionByIDPage: setRoute(
|
||||
"/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId"
|
||||
|
||||
@@ -228,6 +228,16 @@ export enum ProjectPermissionPamSessionActions {
|
||||
// Terminate = "terminate"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionApprovalRequestActions {
|
||||
Read = "read",
|
||||
Create = "create"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionApprovalRequestGrantActions {
|
||||
Read = "read",
|
||||
Revoke = "revoke"
|
||||
}
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
identityId: string;
|
||||
};
|
||||
@@ -339,7 +349,9 @@ export enum ProjectPermissionSub {
|
||||
PamFolders = "pam-folders",
|
||||
PamResources = "pam-resources",
|
||||
PamAccounts = "pam-accounts",
|
||||
PamSessions = "pam-sessions"
|
||||
PamSessions = "pam-sessions",
|
||||
ApprovalRequests = "approval-requests",
|
||||
ApprovalRequestGrants = "approval-request-grants"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -577,6 +589,8 @@ export type ProjectPermissionSet =
|
||||
| (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
|
||||
)
|
||||
]
|
||||
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
|
||||
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]
|
||||
| [ProjectPermissionApprovalRequestActions, ProjectPermissionSub.ApprovalRequests]
|
||||
| [ProjectPermissionApprovalRequestGrantActions, ProjectPermissionSub.ApprovalRequestGrants];
|
||||
|
||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
HCVaultConnectionMethod,
|
||||
HumanitecConnectionMethod,
|
||||
LdapConnectionMethod,
|
||||
MongoDBConnectionMethod,
|
||||
MsSqlConnectionMethod,
|
||||
MySqlConnectionMethod,
|
||||
OktaConnectionMethod,
|
||||
@@ -129,6 +130,7 @@ export const APP_CONNECTION_MAP: Record<
|
||||
[AppConnection.Northflank]: { name: "Northflank", image: "Northflank.png" },
|
||||
[AppConnection.Okta]: { name: "Okta", image: "Okta.png" },
|
||||
[AppConnection.Redis]: { name: "Redis", image: "Redis.png" },
|
||||
[AppConnection.MongoDB]: { name: "MongoDB", image: "MongoDB.png" },
|
||||
[AppConnection.LaravelForge]: {
|
||||
name: "Laravel Forge",
|
||||
image: "Laravel Forge.png",
|
||||
@@ -181,6 +183,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
||||
case OracleDBConnectionMethod.UsernameAndPassword:
|
||||
case AzureADCSConnectionMethod.UsernamePassword:
|
||||
case RedisConnectionMethod.UsernameAndPassword:
|
||||
case MongoDBConnectionMethod.UsernameAndPassword:
|
||||
return { name: "Username & Password", icon: faLock };
|
||||
case HCVaultConnectionMethod.AccessToken:
|
||||
case TeamCityConnectionMethod.AccessToken:
|
||||
|
||||
@@ -54,6 +54,11 @@ export const SECRET_ROTATION_MAP: Record<
|
||||
name: "Redis Credentials",
|
||||
image: "Redis.png",
|
||||
size: 50
|
||||
},
|
||||
[SecretRotation.MongoDBCredentials]: {
|
||||
name: "MongoDB Credentials",
|
||||
image: "MongoDB.png",
|
||||
size: 50
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,7 +72,8 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnectio
|
||||
[SecretRotation.LdapPassword]: AppConnection.LDAP,
|
||||
[SecretRotation.AwsIamUserSecret]: AppConnection.AWS,
|
||||
[SecretRotation.OktaClientSecret]: AppConnection.Okta,
|
||||
[SecretRotation.RedisCredentials]: AppConnection.Redis
|
||||
[SecretRotation.RedisCredentials]: AppConnection.Redis,
|
||||
[SecretRotation.MongoDBCredentials]: AppConnection.MongoDB
|
||||
};
|
||||
|
||||
// if a rotation can potentially have downtime due to rotating a single credential set this to false
|
||||
@@ -81,7 +87,8 @@ export const IS_ROTATION_DUAL_CREDENTIALS: Record<SecretRotation, boolean> = {
|
||||
[SecretRotation.LdapPassword]: false,
|
||||
[SecretRotation.AwsIamUserSecret]: true,
|
||||
[SecretRotation.OktaClientSecret]: true,
|
||||
[SecretRotation.RedisCredentials]: true
|
||||
[SecretRotation.RedisCredentials]: true,
|
||||
[SecretRotation.MongoDBCredentials]: true
|
||||
};
|
||||
|
||||
export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => {
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum AppConnection {
|
||||
Northflank = "northflank",
|
||||
Okta = "okta",
|
||||
Redis = "redis",
|
||||
MongoDB = "mongodb",
|
||||
LaravelForge = "laravel-forge",
|
||||
Chef = "chef"
|
||||
}
|
||||
|
||||
@@ -184,6 +184,10 @@ export type TRedisConnectionOption = TAppConnectionOptionBase & {
|
||||
app: AppConnection.Redis;
|
||||
};
|
||||
|
||||
export type TMongoDBConnectionOption = TAppConnectionOptionBase & {
|
||||
app: AppConnection.MongoDB;
|
||||
};
|
||||
|
||||
export type TDNSMadeEasyConnectionOption = TAppConnectionOptionBase & {
|
||||
app: AppConnection.DNSMadeEasy;
|
||||
};
|
||||
@@ -229,6 +233,8 @@ export type TAppConnectionOption =
|
||||
| TOktaConnectionOption
|
||||
| TAzureAdCsConnectionOption
|
||||
| TLaravelForgeConnectionOption
|
||||
| TRedisConnectionOption
|
||||
| TMongoDBConnectionOption
|
||||
| TChefConnectionOption
|
||||
| TDNSMadeEasyConnectionOption;
|
||||
|
||||
@@ -274,6 +280,7 @@ export type TAppConnectionOptionMap = {
|
||||
[AppConnection.Okta]: TOktaConnectionOption;
|
||||
[AppConnection.AzureADCS]: TAzureAdCsConnectionOption;
|
||||
[AppConnection.Redis]: TRedisConnectionOption;
|
||||
[AppConnection.MongoDB]: TMongoDBConnectionOption;
|
||||
[AppConnection.LaravelForge]: TLaravelForgeConnectionOption;
|
||||
[AppConnection.Chef]: TChefConnectionOption;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import { THerokuConnection } from "./heroku-connection";
|
||||
import { THumanitecConnection } from "./humanitec-connection";
|
||||
import { TLaravelForgeConnection } from "./laravel-forge-connection";
|
||||
import { TLdapConnection } from "./ldap-connection";
|
||||
import { TMongoDBConnection } from "./mongodb-connection";
|
||||
import { TMsSqlConnection } from "./mssql-connection";
|
||||
import { TMySqlConnection } from "./mysql-connection";
|
||||
import { TNetlifyConnection } from "./netlify-connection";
|
||||
@@ -69,6 +70,7 @@ export * from "./heroku-connection";
|
||||
export * from "./humanitec-connection";
|
||||
export * from "./laravel-forge-connection";
|
||||
export * from "./ldap-connection";
|
||||
export * from "./mongodb-connection";
|
||||
export * from "./mssql-connection";
|
||||
export * from "./mysql-connection";
|
||||
export * from "./netlify-connection";
|
||||
@@ -129,6 +131,7 @@ export type TAppConnection =
|
||||
| TNorthflankConnection
|
||||
| TOktaConnection
|
||||
| TRedisConnection
|
||||
| TMongoDBConnection
|
||||
| TChefConnection
|
||||
| TDNSMadeEasyConnection;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
|
||||
|
||||
export enum MongoDBConnectionMethod {
|
||||
UsernameAndPassword = "username-and-password"
|
||||
}
|
||||
|
||||
export type TMongoDBConnectionCredentials = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
tlsEnabled: boolean;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
tlsCertificate?: string;
|
||||
};
|
||||
|
||||
export type TMongoDBConnection = TRootAppConnection & { app: AppConnection.MongoDB } & {
|
||||
method: MongoDBConnectionMethod.UsernameAndPassword;
|
||||
credentials: TMongoDBConnectionCredentials;
|
||||
};
|
||||
10
frontend/src/hooks/api/approvalGrants/index.tsx
Normal file
10
frontend/src/hooks/api/approvalGrants/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export { useRevokeApprovalGrant } from "./mutations";
|
||||
export { approvalGrantQuery } from "./queries";
|
||||
export {
|
||||
ApprovalGrantStatus,
|
||||
type PamAccessGrantAttributes,
|
||||
type TApprovalGrant,
|
||||
type TGetApprovalGrantByIdDTO,
|
||||
type TListApprovalGrantsDTO,
|
||||
type TRevokeApprovalGrantDTO
|
||||
} from "./types";
|
||||
23
frontend/src/hooks/api/approvalGrants/mutations.tsx
Normal file
23
frontend/src/hooks/api/approvalGrants/mutations.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { approvalGrantQuery } from "./queries";
|
||||
import { TApprovalGrant, TRevokeApprovalGrantDTO } from "./types";
|
||||
|
||||
export const useRevokeApprovalGrant = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, grantId, revocationReason }: TRevokeApprovalGrantDTO) => {
|
||||
const { data } = await apiRequest.post<{ grant: TApprovalGrant }>(
|
||||
`/api/v1/approval-policies/${policyType}/grants/${grantId}/revoke`,
|
||||
{ revocationReason }
|
||||
);
|
||||
return data.grant;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalGrantQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
37
frontend/src/hooks/api/approvalGrants/queries.tsx
Normal file
37
frontend/src/hooks/api/approvalGrants/queries.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TApprovalGrant, TGetApprovalGrantByIdDTO, TListApprovalGrantsDTO } from "./types";
|
||||
|
||||
export const approvalGrantQuery = {
|
||||
allKey: () => ["approval-grants"] as const,
|
||||
getByIdKey: (params: TGetApprovalGrantByIdDTO) =>
|
||||
[...approvalGrantQuery.allKey(), "by-id", params] as const,
|
||||
listKey: (params: TListApprovalGrantsDTO) =>
|
||||
[...approvalGrantQuery.allKey(), "list", params] as const,
|
||||
getById: (params: TGetApprovalGrantByIdDTO) =>
|
||||
queryOptions({
|
||||
queryKey: approvalGrantQuery.getByIdKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ grant: TApprovalGrant }>(
|
||||
`/api/v1/approval-policies/${params.policyType}/grants/${params.grantId}`
|
||||
);
|
||||
return data.grant;
|
||||
}
|
||||
}),
|
||||
list: (params: TListApprovalGrantsDTO) =>
|
||||
queryOptions({
|
||||
queryKey: approvalGrantQuery.listKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
grants: TApprovalGrant[];
|
||||
}>(`/api/v1/approval-policies/${params.policyType}/grants`, {
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
});
|
||||
return data.grants;
|
||||
}
|
||||
})
|
||||
};
|
||||
46
frontend/src/hooks/api/approvalGrants/types.ts
Normal file
46
frontend/src/hooks/api/approvalGrants/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ApprovalPolicyType } from "../approvalPolicies";
|
||||
|
||||
export enum ApprovalGrantStatus {
|
||||
Active = "active",
|
||||
Expired = "expired",
|
||||
Revoked = "revoked"
|
||||
}
|
||||
|
||||
// PAM Access Grant Attributes
|
||||
export type PamAccessGrantAttributes = {
|
||||
accountPath: string;
|
||||
accessDuration: string;
|
||||
};
|
||||
|
||||
// Base Grant Type
|
||||
export type TApprovalGrant = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
requestId: string | null;
|
||||
granteeUserId: string | null;
|
||||
revokedByUserId: string | null;
|
||||
revocationReason: string | null;
|
||||
status: ApprovalGrantStatus;
|
||||
type: ApprovalPolicyType;
|
||||
attributes: PamAccessGrantAttributes;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
revokedAt: string | null;
|
||||
};
|
||||
|
||||
// DTOs
|
||||
export type TListApprovalGrantsDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TGetApprovalGrantByIdDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
grantId: string;
|
||||
};
|
||||
|
||||
export type TRevokeApprovalGrantDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
grantId: string;
|
||||
revocationReason?: string;
|
||||
};
|
||||
19
frontend/src/hooks/api/approvalPolicies/index.tsx
Normal file
19
frontend/src/hooks/api/approvalPolicies/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
useCreateApprovalPolicy,
|
||||
useDeleteApprovalPolicy,
|
||||
useUpdateApprovalPolicy
|
||||
} from "./mutations";
|
||||
export { approvalPolicyQuery } from "./queries";
|
||||
export {
|
||||
type ApprovalPolicyStep,
|
||||
ApprovalPolicyType,
|
||||
ApproverType,
|
||||
type PamAccessPolicyConditions,
|
||||
type PamAccessPolicyConstraints,
|
||||
type TApprovalPolicy,
|
||||
type TCreateApprovalPolicyDTO,
|
||||
type TDeleteApprovalPolicyDTO,
|
||||
type TGetApprovalPolicyByIdDTO,
|
||||
type TListApprovalPoliciesDTO,
|
||||
type TUpdateApprovalPolicyDTO
|
||||
} from "./types";
|
||||
58
frontend/src/hooks/api/approvalPolicies/mutations.tsx
Normal file
58
frontend/src/hooks/api/approvalPolicies/mutations.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { approvalPolicyQuery } from "./queries";
|
||||
import {
|
||||
TApprovalPolicy,
|
||||
TCreateApprovalPolicyDTO,
|
||||
TDeleteApprovalPolicyDTO,
|
||||
TUpdateApprovalPolicyDTO
|
||||
} from "./types";
|
||||
|
||||
export const useCreateApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, ...dto }: TCreateApprovalPolicyDTO) => {
|
||||
const { data } = await apiRequest.post<{ policy: TApprovalPolicy }>(
|
||||
`/api/v1/approval-policies/${policyType}`,
|
||||
dto
|
||||
);
|
||||
return data.policy;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalPolicyQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, policyId, ...updates }: TUpdateApprovalPolicyDTO) => {
|
||||
const { data } = await apiRequest.patch<{ policy: TApprovalPolicy }>(
|
||||
`/api/v1/approval-policies/${policyType}/${policyId}`,
|
||||
updates
|
||||
);
|
||||
return data.policy;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalPolicyQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, policyId }: TDeleteApprovalPolicyDTO) => {
|
||||
const { data } = await apiRequest.delete<{ policyId: string }>(
|
||||
`/api/v1/approval-policies/${policyType}/${policyId}`
|
||||
);
|
||||
return data.policyId;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalPolicyQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
37
frontend/src/hooks/api/approvalPolicies/queries.tsx
Normal file
37
frontend/src/hooks/api/approvalPolicies/queries.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TApprovalPolicy, TGetApprovalPolicyByIdDTO, TListApprovalPoliciesDTO } from "./types";
|
||||
|
||||
export const approvalPolicyQuery = {
|
||||
allKey: () => ["approval-policies"] as const,
|
||||
getByIdKey: (params: TGetApprovalPolicyByIdDTO) =>
|
||||
[...approvalPolicyQuery.allKey(), "by-id", params] as const,
|
||||
listKey: (params: TListApprovalPoliciesDTO) =>
|
||||
[...approvalPolicyQuery.allKey(), "list", params] as const,
|
||||
getById: (params: TGetApprovalPolicyByIdDTO) =>
|
||||
queryOptions({
|
||||
queryKey: approvalPolicyQuery.getByIdKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ policy: TApprovalPolicy }>(
|
||||
`/api/v1/approval-policies/${params.policyType}/${params.policyId}`
|
||||
);
|
||||
return data.policy;
|
||||
}
|
||||
}),
|
||||
list: (params: TListApprovalPoliciesDTO) =>
|
||||
queryOptions({
|
||||
queryKey: approvalPolicyQuery.listKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
policies: TApprovalPolicy[];
|
||||
}>(`/api/v1/approval-policies/${params.policyType}`, {
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
});
|
||||
return data.policies;
|
||||
}
|
||||
})
|
||||
};
|
||||
83
frontend/src/hooks/api/approvalPolicies/types.ts
Normal file
83
frontend/src/hooks/api/approvalPolicies/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export enum ApprovalPolicyType {
|
||||
PamAccess = "pam-access"
|
||||
}
|
||||
|
||||
export enum ApproverType {
|
||||
Group = "group",
|
||||
User = "user"
|
||||
}
|
||||
|
||||
export type ApprovalPolicyStep = {
|
||||
name?: string | null;
|
||||
requiredApprovals: number;
|
||||
notifyApprovers?: boolean;
|
||||
approvers: {
|
||||
type: ApproverType;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type PamAccessPolicyConditions = {
|
||||
accountPaths: string[];
|
||||
}[];
|
||||
|
||||
export type PamAccessPolicyConstraints = {
|
||||
accessDuration: {
|
||||
min: string;
|
||||
max: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TApprovalPolicy = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
maxRequestTtl?: string | null;
|
||||
type: ApprovalPolicyType;
|
||||
conditions: {
|
||||
version: number;
|
||||
conditions: PamAccessPolicyConditions;
|
||||
};
|
||||
constraints: {
|
||||
version: number;
|
||||
constraints: PamAccessPolicyConstraints;
|
||||
};
|
||||
steps: ApprovalPolicyStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TCreateApprovalPolicyDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
projectId: string;
|
||||
name: string;
|
||||
maxRequestTtl?: string | null;
|
||||
conditions: PamAccessPolicyConditions;
|
||||
constraints: PamAccessPolicyConstraints;
|
||||
steps: ApprovalPolicyStep[];
|
||||
};
|
||||
|
||||
export type TUpdateApprovalPolicyDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
policyId: string;
|
||||
name?: string;
|
||||
maxRequestTtl?: string | null;
|
||||
conditions?: PamAccessPolicyConditions;
|
||||
constraints?: PamAccessPolicyConstraints;
|
||||
steps?: ApprovalPolicyStep[];
|
||||
};
|
||||
|
||||
export type TGetApprovalPolicyByIdDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
policyId: string;
|
||||
};
|
||||
|
||||
export type TListApprovalPoliciesDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TDeleteApprovalPolicyDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
policyId: string;
|
||||
};
|
||||
20
frontend/src/hooks/api/approvalRequests/index.tsx
Normal file
20
frontend/src/hooks/api/approvalRequests/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
useApproveApprovalRequest,
|
||||
useCancelApprovalRequest,
|
||||
useCreateApprovalRequest,
|
||||
useRejectApprovalRequest
|
||||
} from "./mutations";
|
||||
export { approvalRequestQuery } from "./queries";
|
||||
export {
|
||||
type ApprovalRequestApproval,
|
||||
ApprovalRequestStatus,
|
||||
type ApprovalRequestStep,
|
||||
ApprovalRequestStepStatus,
|
||||
type PamAccessRequestData,
|
||||
type TApprovalRequest,
|
||||
type TApproveApprovalRequestDTO,
|
||||
type TCreateApprovalRequestDTO,
|
||||
type TGetApprovalRequestByIdDTO,
|
||||
type TListApprovalRequestsDTO,
|
||||
type TRejectApprovalRequestDTO
|
||||
} from "./types";
|
||||
75
frontend/src/hooks/api/approvalRequests/mutations.tsx
Normal file
75
frontend/src/hooks/api/approvalRequests/mutations.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { approvalRequestQuery } from "./queries";
|
||||
import {
|
||||
TApprovalRequest,
|
||||
TApproveApprovalRequestDTO,
|
||||
TCancelApprovalRequestDTO,
|
||||
TCreateApprovalRequestDTO,
|
||||
TRejectApprovalRequestDTO
|
||||
} from "./types";
|
||||
|
||||
export const useCreateApprovalRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, ...dto }: TCreateApprovalRequestDTO) => {
|
||||
const { data } = await apiRequest.post<{ request: TApprovalRequest }>(
|
||||
`/api/v1/approval-policies/${policyType}/requests`,
|
||||
dto
|
||||
);
|
||||
return data.request;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveApprovalRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, requestId, comment }: TApproveApprovalRequestDTO) => {
|
||||
const { data } = await apiRequest.post<{ request: TApprovalRequest }>(
|
||||
`/api/v1/approval-policies/${policyType}/requests/${requestId}/approve`,
|
||||
{ comment }
|
||||
);
|
||||
return data.request;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useRejectApprovalRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, requestId, comment }: TRejectApprovalRequestDTO) => {
|
||||
const { data } = await apiRequest.post<{ request: TApprovalRequest }>(
|
||||
`/api/v1/approval-policies/${policyType}/requests/${requestId}/reject`,
|
||||
{ comment }
|
||||
);
|
||||
return data.request;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelApprovalRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ policyType, requestId }: TCancelApprovalRequestDTO) => {
|
||||
const { data } = await apiRequest.post<{ request: TApprovalRequest }>(
|
||||
`/api/v1/approval-policies/${policyType}/requests/${requestId}/cancel`
|
||||
);
|
||||
return data.request;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
37
frontend/src/hooks/api/approvalRequests/queries.tsx
Normal file
37
frontend/src/hooks/api/approvalRequests/queries.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TApprovalRequest, TGetApprovalRequestByIdDTO, TListApprovalRequestsDTO } from "./types";
|
||||
|
||||
export const approvalRequestQuery = {
|
||||
allKey: () => ["approval-requests"] as const,
|
||||
getByIdKey: (params: TGetApprovalRequestByIdDTO) =>
|
||||
[...approvalRequestQuery.allKey(), "by-id", params] as const,
|
||||
listKey: (params: TListApprovalRequestsDTO) =>
|
||||
[...approvalRequestQuery.allKey(), "list", params] as const,
|
||||
getById: (params: TGetApprovalRequestByIdDTO) =>
|
||||
queryOptions({
|
||||
queryKey: approvalRequestQuery.getByIdKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ request: TApprovalRequest }>(
|
||||
`/api/v1/approval-policies/${params.policyType}/requests/${params.requestId}`
|
||||
);
|
||||
return data.request;
|
||||
}
|
||||
}),
|
||||
list: (params: TListApprovalRequestsDTO) =>
|
||||
queryOptions({
|
||||
queryKey: approvalRequestQuery.listKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
requests: TApprovalRequest[];
|
||||
}>(`/api/v1/approval-policies/${params.policyType}/requests`, {
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
});
|
||||
return data.requests;
|
||||
}
|
||||
})
|
||||
};
|
||||
110
frontend/src/hooks/api/approvalRequests/types.ts
Normal file
110
frontend/src/hooks/api/approvalRequests/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ApprovalPolicyType, ApproverType } from "../approvalPolicies";
|
||||
|
||||
export enum ApprovalRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
Expired = "expired",
|
||||
Cancelled = "cancelled"
|
||||
}
|
||||
|
||||
export enum ApprovalRequestStepStatus {
|
||||
Pending = "pending",
|
||||
InProgress = "in-progress",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected"
|
||||
}
|
||||
|
||||
export enum ApprovalRequestApprovalDecision {
|
||||
Approved = "approved",
|
||||
Rejected = "rejected"
|
||||
}
|
||||
|
||||
export type ApprovalRequestApproval = {
|
||||
id: string;
|
||||
stepId: string;
|
||||
approverUserId: string;
|
||||
decision: ApprovalRequestApprovalDecision.Approved;
|
||||
comment?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ApprovalRequestStep = {
|
||||
id: string;
|
||||
requestId: string;
|
||||
name?: string | null;
|
||||
requiredApprovals: number;
|
||||
notifyApprovers?: boolean | null;
|
||||
stepNumber: number;
|
||||
status: ApprovalRequestStepStatus;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
approvers: {
|
||||
type: ApproverType;
|
||||
id: string;
|
||||
}[];
|
||||
approvals: ApprovalRequestApproval[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PamAccessRequestData = {
|
||||
accountPath: string;
|
||||
accessDuration: string;
|
||||
};
|
||||
|
||||
export type TApprovalRequest = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
policyId: string;
|
||||
type: ApprovalPolicyType;
|
||||
status: ApprovalRequestStatus;
|
||||
requesterId: string;
|
||||
requesterName: string;
|
||||
requesterEmail: string;
|
||||
justification?: string | null;
|
||||
expiresAt?: string | null;
|
||||
requestData: {
|
||||
version: number;
|
||||
requestData: PamAccessRequestData;
|
||||
};
|
||||
steps: ApprovalRequestStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TCreateApprovalRequestDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
projectId: string;
|
||||
justification?: string | null;
|
||||
requestDuration?: string | null;
|
||||
requestData: PamAccessRequestData;
|
||||
};
|
||||
|
||||
export type TGetApprovalRequestByIdDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export type TListApprovalRequestsDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TApproveApprovalRequestDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
requestId: string;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export type TRejectApprovalRequestDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
requestId: string;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export type TCancelApprovalRequestDTO = {
|
||||
policyType: ApprovalPolicyType;
|
||||
requestId: string;
|
||||
};
|
||||
@@ -291,7 +291,22 @@ export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.SIGN_CERTIFICATE_FROM_PROFILE]: "Sign Certificate From Profile",
|
||||
[EventType.ORDER_CERTIFICATE_FROM_PROFILE]: "Order Certificate From Profile",
|
||||
[EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE]:
|
||||
"Get Certificate Profile Latest Active Bundle"
|
||||
"Get Certificate Profile Latest Active Bundle",
|
||||
|
||||
[EventType.APPROVAL_POLICY_CREATE]: "Create Approval Policy",
|
||||
[EventType.APPROVAL_POLICY_UPDATE]: "Update Approval Policy",
|
||||
[EventType.APPROVAL_POLICY_DELETE]: "Delete Approval Policy",
|
||||
[EventType.APPROVAL_POLICY_LIST]: "List Approval Policies",
|
||||
[EventType.APPROVAL_POLICY_GET]: "Get Approval Policy",
|
||||
[EventType.APPROVAL_REQUEST_GET]: "Get Approval Request",
|
||||
[EventType.APPROVAL_REQUEST_LIST]: "List Approval Requests",
|
||||
[EventType.APPROVAL_REQUEST_CREATE]: "Create Approval Request",
|
||||
[EventType.APPROVAL_REQUEST_APPROVE]: "Approve Approval Request",
|
||||
[EventType.APPROVAL_REQUEST_REJECT]: "Reject Approval Request",
|
||||
[EventType.APPROVAL_REQUEST_CANCEL]: "Cancel Approval Request",
|
||||
[EventType.APPROVAL_REQUEST_GRANT_LIST]: "List Approval Request Grants",
|
||||
[EventType.APPROVAL_REQUEST_GRANT_GET]: "Get Approval Request Grant",
|
||||
[EventType.APPROVAL_REQUEST_GRANT_REVOKE]: "Revoke Approval Request Grant"
|
||||
};
|
||||
|
||||
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
|
||||
@@ -309,7 +324,21 @@ const sharedProjectEvents = [
|
||||
EventType.REMOVE_PROJECT_MEMBER,
|
||||
EventType.CREATE_PROJECT_ROLE,
|
||||
EventType.UPDATE_PROJECT_ROLE,
|
||||
EventType.DELETE_PROJECT_ROLE
|
||||
EventType.DELETE_PROJECT_ROLE,
|
||||
EventType.APPROVAL_POLICY_CREATE,
|
||||
EventType.APPROVAL_POLICY_UPDATE,
|
||||
EventType.APPROVAL_POLICY_DELETE,
|
||||
EventType.APPROVAL_POLICY_LIST,
|
||||
EventType.APPROVAL_POLICY_GET,
|
||||
EventType.APPROVAL_REQUEST_GET,
|
||||
EventType.APPROVAL_REQUEST_LIST,
|
||||
EventType.APPROVAL_REQUEST_CREATE,
|
||||
EventType.APPROVAL_REQUEST_APPROVE,
|
||||
EventType.APPROVAL_REQUEST_REJECT,
|
||||
EventType.APPROVAL_REQUEST_CANCEL,
|
||||
EventType.APPROVAL_REQUEST_GRANT_LIST,
|
||||
EventType.APPROVAL_REQUEST_GRANT_GET,
|
||||
EventType.APPROVAL_REQUEST_GRANT_REVOKE
|
||||
];
|
||||
|
||||
export const projectToEventsMap: Partial<Record<ProjectType, EventType[]>> = {
|
||||
|
||||
@@ -282,5 +282,20 @@ export enum EventType {
|
||||
ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
|
||||
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
|
||||
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
|
||||
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle"
|
||||
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
|
||||
|
||||
APPROVAL_POLICY_CREATE = "approval-policy-create",
|
||||
APPROVAL_POLICY_UPDATE = "approval-policy-update",
|
||||
APPROVAL_POLICY_DELETE = "approval-policy-delete",
|
||||
APPROVAL_POLICY_LIST = "approval-policy-list",
|
||||
APPROVAL_POLICY_GET = "approval-policy-get",
|
||||
APPROVAL_REQUEST_GET = "approval-request-get",
|
||||
APPROVAL_REQUEST_LIST = "approval-request-list",
|
||||
APPROVAL_REQUEST_CREATE = "approval-request-create",
|
||||
APPROVAL_REQUEST_APPROVE = "approval-request-approve",
|
||||
APPROVAL_REQUEST_REJECT = "approval-request-reject",
|
||||
APPROVAL_REQUEST_CANCEL = "approval-request-cancel",
|
||||
APPROVAL_REQUEST_GRANT_LIST = "approval-request-grant-list",
|
||||
APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get",
|
||||
APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
|
||||
}
|
||||
|
||||
export type TIdentityProjectPrivilege = {
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export * from "./accessApproval";
|
||||
export * from "./admin";
|
||||
export * from "./apiKeys";
|
||||
export * from "./approvalGrants";
|
||||
export * from "./approvalPolicies";
|
||||
export * from "./approvalRequests";
|
||||
export * from "./assumePrivileges";
|
||||
export * from "./auditLogs";
|
||||
export * from "./auditLogStreams";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { kmsKeys } from "./queries";
|
||||
import {
|
||||
AddExternalKmsType,
|
||||
ExternalKmsGcpSchemaType,
|
||||
ExternalKmsProvider,
|
||||
KmsGcpKeyFetchAuthType,
|
||||
KmsType,
|
||||
UpdateExternalKmsType
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
export const useAddExternalKms = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, description, provider }: AddExternalKmsType) => {
|
||||
const { data } = await apiRequest.post("/api/v1/external-kms", {
|
||||
mutationFn: async ({ name, description, configuration }: AddExternalKmsType) => {
|
||||
const providerPath = configuration.type === ExternalKmsProvider.Aws ? "aws" : "gcp";
|
||||
const { data } = await apiRequest.post(`/api/v1/external-kms/${providerPath}`, {
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
configuration: configuration.inputs
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -29,21 +31,21 @@ export const useAddExternalKms = (orgId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateExternalKms = (orgId: string) => {
|
||||
export const useUpdateExternalKms = (orgId: string, provider: ExternalKmsProvider) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
kmsId,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
configuration
|
||||
}: {
|
||||
kmsId: string;
|
||||
} & UpdateExternalKmsType) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, {
|
||||
const { data } = await apiRequest.patch(`/api/v1/external-kms/${provider}/${kmsId}`, {
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
configuration: configuration?.inputs
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -58,8 +60,8 @@ export const useUpdateExternalKms = (orgId: string) => {
|
||||
export const useRemoveExternalKms = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (kmsId: string) => {
|
||||
const { data } = await apiRequest.delete(`/api/v1/external-kms/${kmsId}`);
|
||||
mutationFn: async ({ kmsId, provider }: { kmsId: string; provider: ExternalKmsProvider }) => {
|
||||
const { data } = await apiRequest.delete(`/api/v1/external-kms/${provider}/${kmsId}`);
|
||||
|
||||
return data;
|
||||
},
|
||||
@@ -130,11 +132,19 @@ export const useExternalKmsFetchGcpKeys = (orgId: string) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", {
|
||||
authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms,
|
||||
region: gcpRegion,
|
||||
...rest
|
||||
});
|
||||
const requestBody = credential
|
||||
? {
|
||||
authMethod: KmsGcpKeyFetchAuthType.Credential,
|
||||
region: gcpRegion,
|
||||
credential
|
||||
}
|
||||
: {
|
||||
authMethod: KmsGcpKeyFetchAuthType.Kms,
|
||||
region: gcpRegion,
|
||||
kmsId
|
||||
};
|
||||
|
||||
const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", requestBody);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { Kms, KmsListEntry } from "./types";
|
||||
import { ExternalKmsProvider, Kms, KmsListEntry } from "./types";
|
||||
|
||||
export const kmsKeys = {
|
||||
getExternalKmsList: (orgId: string) => ["get-all-external-kms", { orgId }],
|
||||
@@ -23,15 +23,19 @@ export const useGetExternalKmsList = (orgId: string, { enabled }: { enabled?: bo
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetExternalKmsById = (kmsId: string) => {
|
||||
export const useGetExternalKmsById = ({
|
||||
kmsId,
|
||||
provider
|
||||
}: {
|
||||
kmsId: string;
|
||||
provider: ExternalKmsProvider;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: kmsKeys.getExternalKmsById(kmsId),
|
||||
enabled: Boolean(kmsId),
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { externalKms }
|
||||
} = await apiRequest.get<{ externalKms: Kms }>(`/api/v1/external-kms/${kmsId}`);
|
||||
return externalKms;
|
||||
const { data } = await apiRequest.get<Kms>(`/api/v1/external-kms/${provider}/${kmsId}`);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,12 +8,13 @@ export type Kms = {
|
||||
description: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
external: {
|
||||
externalKms: {
|
||||
id: string;
|
||||
status: string;
|
||||
statusDetails: string;
|
||||
provider: string;
|
||||
providerInput: Record<string, any>;
|
||||
configuration: Record<string, any>;
|
||||
credentialsHash?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -123,14 +124,14 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
|
||||
export const AddExternalKmsSchema = z.object({
|
||||
name: slugSchema({ min: 1, field: "Alias" }),
|
||||
description: z.string().trim().optional(),
|
||||
provider: ExternalKmsInputSchema
|
||||
configuration: ExternalKmsInputSchema
|
||||
});
|
||||
|
||||
export type AddExternalKmsType = z.infer<typeof AddExternalKmsSchema>;
|
||||
|
||||
// we need separate schema for update because the credential field is not required on GCP
|
||||
export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema.partial() }),
|
||||
z.object({
|
||||
type: z.literal(ExternalKmsProvider.Gcp),
|
||||
inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })
|
||||
@@ -144,9 +145,10 @@ export const UpdateExternalKmsSchema = z.object({
|
||||
.min(1)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Alias must be a valid slug"
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
description: z.string().trim().optional(),
|
||||
provider: ExternalKmsUpdateInputSchema
|
||||
configuration: ExternalKmsUpdateInputSchema.optional()
|
||||
});
|
||||
|
||||
export type UpdateExternalKmsType = z.infer<typeof UpdateExternalKmsSchema>;
|
||||
|
||||
@@ -16,7 +16,8 @@ export enum PamResourceType {
|
||||
CockroachDB = "cockroachdb",
|
||||
Elasticsearch = "elasticsearch",
|
||||
Snowflake = "snowflake",
|
||||
DynamoDB = "dynamodb"
|
||||
DynamoDB = "dynamodb",
|
||||
AwsIam = "aws-iam"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
|
||||
@@ -20,5 +20,6 @@ export const PAM_RESOURCE_TYPE_MAP: Record<
|
||||
[PamResourceType.CockroachDB]: { name: "CockroachDB", image: "CockroachDB.png" },
|
||||
[PamResourceType.Elasticsearch]: { name: "Elasticsearch", image: "Elastic.png" },
|
||||
[PamResourceType.Snowflake]: { name: "Snowflake", image: "Snowflake.png" },
|
||||
[PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 }
|
||||
[PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 },
|
||||
[PamResourceType.AwsIam]: { name: "AWS IAM", image: "Amazon Web Services.png" }
|
||||
};
|
||||
|
||||
@@ -120,6 +120,45 @@ export const useDeletePamAccount = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export type TAccessPamAccountDTO = {
|
||||
accountId: string;
|
||||
accountPath: string;
|
||||
projectId: string;
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type TAccessPamAccountResponse = {
|
||||
sessionId: string;
|
||||
resourceType: string;
|
||||
consoleUrl?: string;
|
||||
metadata?: Record<string, string | undefined>;
|
||||
relayClientCertificate?: string;
|
||||
relayClientPrivateKey?: string;
|
||||
relayServerCertificateChain?: string;
|
||||
gatewayClientCertificate?: string;
|
||||
gatewayClientPrivateKey?: string;
|
||||
gatewayServerCertificateChain?: string;
|
||||
relayHost?: string;
|
||||
};
|
||||
|
||||
export const useAccessPamAccount = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ accountId, accountPath, projectId, duration }: TAccessPamAccountDTO) => {
|
||||
const { data } = await apiRequest.post<TAccessPamAccountResponse>(
|
||||
"/api/v1/pam/accounts/access",
|
||||
{
|
||||
accountId,
|
||||
accountPath,
|
||||
projectId,
|
||||
duration
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Folders
|
||||
export const useCreatePamFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
25
frontend/src/hooks/api/pam/types/aws-iam-resource.ts
Normal file
25
frontend/src/hooks/api/pam/types/aws-iam-resource.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
import { TBasePamAccount } from "./base-account";
|
||||
import { TBasePamResource } from "./base-resource";
|
||||
|
||||
export type TAwsIamConnectionDetails = {
|
||||
roleArn: string;
|
||||
};
|
||||
|
||||
export type TAwsIamCredentials = {
|
||||
targetRoleArn: string;
|
||||
defaultSessionDuration: number;
|
||||
};
|
||||
|
||||
export type TAwsIamResource = Omit<TBasePamResource, "gatewayId"> & {
|
||||
resourceType: PamResourceType.AwsIam;
|
||||
gatewayId?: string | null;
|
||||
connectionDetails: TAwsIamConnectionDetails;
|
||||
};
|
||||
|
||||
export type TAwsIamAccount = Omit<
|
||||
TBasePamAccount,
|
||||
"rotationEnabled" | "rotationIntervalSeconds" | "lastRotatedAt"
|
||||
> & {
|
||||
credentials: TAwsIamCredentials;
|
||||
};
|
||||
@@ -6,17 +6,19 @@ import {
|
||||
PamResourceType,
|
||||
PamSessionStatus
|
||||
} from "../enums";
|
||||
import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource";
|
||||
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
|
||||
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
||||
import { TSSHAccount, TSSHResource } from "./ssh-resource";
|
||||
|
||||
export * from "./aws-iam-resource";
|
||||
export * from "./mysql-resource";
|
||||
export * from "./postgres-resource";
|
||||
export * from "./ssh-resource";
|
||||
|
||||
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
|
||||
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource;
|
||||
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount;
|
||||
|
||||
export type TPamFolder = {
|
||||
id: string;
|
||||
|
||||
@@ -8,7 +8,8 @@ export enum SecretRotation {
|
||||
LdapPassword = "ldap-password",
|
||||
AwsIamUserSecret = "aws-iam-user-secret",
|
||||
OktaClientSecret = "okta-client-secret",
|
||||
RedisCredentials = "redis-credentials"
|
||||
RedisCredentials = "redis-credentials",
|
||||
MongoDBCredentials = "mongodb-credentials"
|
||||
}
|
||||
|
||||
export enum SecretRotationStatus {
|
||||
|
||||
@@ -31,6 +31,11 @@ import { TSqlCredentialsRotationOption } from "@app/hooks/api/secretRotationsV2/
|
||||
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import {
|
||||
TMongoDBCredentialsRotation,
|
||||
TMongoDBCredentialsRotationGeneratedCredentialsResponse,
|
||||
TMongoDBCredentialsRotationOption
|
||||
} from "./mongodb-credentials-rotation";
|
||||
import {
|
||||
TMySqlCredentialsRotation,
|
||||
TMySqlCredentialsRotationGeneratedCredentialsResponse
|
||||
@@ -61,6 +66,7 @@ export type TSecretRotationV2 = (
|
||||
| TAwsIamUserSecretRotation
|
||||
| TOktaClientSecretRotation
|
||||
| TRedisCredentialsRotation
|
||||
| TMongoDBCredentialsRotation
|
||||
) & {
|
||||
secrets: (SecretV3RawSanitized | null)[];
|
||||
};
|
||||
@@ -72,7 +78,8 @@ export type TSecretRotationV2Option =
|
||||
| TLdapPasswordRotationOption
|
||||
| TAwsIamUserSecretRotationOption
|
||||
| TOktaClientSecretRotationOption
|
||||
| TRedisCredentialsRotationOption;
|
||||
| TRedisCredentialsRotationOption
|
||||
| TMongoDBCredentialsRotationOption;
|
||||
|
||||
export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] };
|
||||
|
||||
@@ -88,7 +95,8 @@ export type TViewSecretRotationGeneratedCredentialsResponse =
|
||||
| TLdapPasswordRotationGeneratedCredentialsResponse
|
||||
| TAwsIamUserSecretRotationGeneratedCredentialsResponse
|
||||
| TOktaClientSecretRotationGeneratedCredentialsResponse
|
||||
| TRedisCredentialsRotationGeneratedCredentialsResponse;
|
||||
| TRedisCredentialsRotationGeneratedCredentialsResponse
|
||||
| TMongoDBCredentialsRotationGeneratedCredentialsResponse;
|
||||
|
||||
export type TCreateSecretRotationV2DTO = DiscriminativePick<
|
||||
TSecretRotationV2,
|
||||
@@ -142,6 +150,7 @@ export type TSecretRotationOptionMap = {
|
||||
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption;
|
||||
[SecretRotation.OktaClientSecret]: TOktaClientSecretRotationOption;
|
||||
[SecretRotation.RedisCredentials]: TRedisCredentialsRotationOption;
|
||||
[SecretRotation.MongoDBCredentials]: TMongoDBCredentialsRotationOption;
|
||||
};
|
||||
|
||||
export type TSecretRotationGeneratedCredentialsResponseMap = {
|
||||
@@ -155,4 +164,5 @@ export type TSecretRotationGeneratedCredentialsResponseMap = {
|
||||
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse;
|
||||
[SecretRotation.OktaClientSecret]: TOktaClientSecretRotationGeneratedCredentialsResponse;
|
||||
[SecretRotation.RedisCredentials]: TRedisCredentialsRotationGeneratedCredentialsResponse;
|
||||
[SecretRotation.MongoDBCredentials]: TMongoDBCredentialsRotationGeneratedCredentialsResponse;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
|
||||
import {
|
||||
TSecretRotationV2Base,
|
||||
TSecretRotationV2GeneratedCredentialsResponseBase,
|
||||
TSqlCredentialsRotationGeneratedCredentials,
|
||||
TSqlCredentialsRotationProperties
|
||||
} from "@app/hooks/api/secretRotationsV2/types/shared";
|
||||
|
||||
export type TMongoDBCredentialsRotation = TSecretRotationV2Base & {
|
||||
type: SecretRotation.MongoDBCredentials;
|
||||
} & TSqlCredentialsRotationProperties;
|
||||
|
||||
export type TMongoDBCredentialsRotationGeneratedCredentialsResponse =
|
||||
TSecretRotationV2GeneratedCredentialsResponseBase<
|
||||
SecretRotation.MongoDBCredentials,
|
||||
TSqlCredentialsRotationGeneratedCredentials
|
||||
>;
|
||||
|
||||
export type TMongoDBCredentialsRotationOption = {
|
||||
name: string;
|
||||
type: SecretRotation.MongoDBCredentials;
|
||||
connection: AppConnection.MongoDB;
|
||||
template: {
|
||||
createUserStatement: string;
|
||||
secretsMapping: TMongoDBCredentialsRotation["secretsMapping"];
|
||||
};
|
||||
};
|
||||
@@ -78,6 +78,7 @@ export type TUserMembership = {
|
||||
scope: string;
|
||||
scopeOrgId: string;
|
||||
actorUserId: string;
|
||||
actorGroupId: string;
|
||||
};
|
||||
|
||||
export type TProjectMembership = {
|
||||
|
||||
@@ -69,6 +69,23 @@ export const PamLayout = () => {
|
||||
>
|
||||
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Sessions</Tab>}
|
||||
</Link>
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/approvals"
|
||||
params={{
|
||||
orgId: currentOrg.id,
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Tab
|
||||
value={
|
||||
isActive || location.pathname.match(/\/approvals\/|\/i/) ? "selected" : ""
|
||||
}
|
||||
>
|
||||
Approvals
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/access-management"
|
||||
params={{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faDownload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -41,55 +44,119 @@ export type ExportOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => {
|
||||
const [selectedFormat, setSelectedFormat] = useState<CertificateExportFormat>("pem");
|
||||
const [pkcs12Options, setPkcs12Options] = useState({
|
||||
password: "",
|
||||
alias: ""
|
||||
});
|
||||
const exportFormSchema = z
|
||||
.object({
|
||||
format: z.enum(["pem", "pkcs12"]),
|
||||
pkcs12Password: z.string().optional(),
|
||||
pkcs12Alias: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.format === "pkcs12") {
|
||||
return data.pkcs12Password && data.pkcs12Alias && data.pkcs12Alias.trim() !== "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "PKCS12 password and alias are required when using PKCS12 format",
|
||||
path: ["pkcs12Password"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.format === "pkcs12") {
|
||||
return data.pkcs12Password && data.pkcs12Password.length >= 6;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "PKCS12 password must be 6 characters or longer",
|
||||
path: ["pkcs12Password"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.format === "pkcs12" && data.pkcs12Password) {
|
||||
return data.pkcs12Password.length >= 6;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Password must be at least 6 characters long",
|
||||
path: ["pkcs12Password"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.format === "pkcs12") {
|
||||
return data.pkcs12Alias && data.pkcs12Alias.trim() !== "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Certificate alias is required",
|
||||
path: ["pkcs12Alias"]
|
||||
}
|
||||
);
|
||||
|
||||
type ExportFormData = z.infer<typeof exportFormSchema>;
|
||||
|
||||
export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => {
|
||||
const { certificateId, serialNumber } =
|
||||
(popUp?.certificateExport?.data as {
|
||||
certificateId: string;
|
||||
serialNumber: string;
|
||||
}) || {};
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<ExportFormData>({
|
||||
resolver: zodResolver(exportFormSchema),
|
||||
defaultValues: {
|
||||
format: "pem",
|
||||
pkcs12Password: "",
|
||||
pkcs12Alias: ""
|
||||
}
|
||||
});
|
||||
|
||||
const selectedFormat = watch("format");
|
||||
|
||||
// Reset form whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (popUp?.certificateExport?.isOpen) {
|
||||
setSelectedFormat("pem");
|
||||
setPkcs12Options({
|
||||
password: "",
|
||||
alias: ""
|
||||
reset({
|
||||
format: "pem",
|
||||
pkcs12Password: "",
|
||||
pkcs12Alias: ""
|
||||
});
|
||||
}
|
||||
}, [popUp?.certificateExport?.isOpen]);
|
||||
}, [popUp?.certificateExport?.isOpen, reset]);
|
||||
|
||||
const isFormValid = () => {
|
||||
if (selectedFormat === "pkcs12") {
|
||||
return pkcs12Options.password.length >= 6 && pkcs12Options.alias.trim() !== "";
|
||||
const onFormSubmit = (data: ExportFormData) => {
|
||||
if (!(certificateId || serialNumber)) return;
|
||||
|
||||
const options: ExportOptions = {};
|
||||
|
||||
if (data.format === "pkcs12") {
|
||||
options.pkcs12 = {
|
||||
password: data.pkcs12Password!,
|
||||
alias: data.pkcs12Alias!
|
||||
};
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if ((certificateId || serialNumber) && isFormValid()) {
|
||||
const options: ExportOptions = {};
|
||||
|
||||
if (selectedFormat === "pkcs12") {
|
||||
options.pkcs12 = pkcs12Options;
|
||||
}
|
||||
|
||||
onFormatSelected(
|
||||
selectedFormat,
|
||||
{
|
||||
certificateId,
|
||||
serialNumber
|
||||
},
|
||||
options
|
||||
);
|
||||
handlePopUpToggle("certificateExport", false);
|
||||
}
|
||||
onFormatSelected(
|
||||
data.format,
|
||||
{
|
||||
certificateId,
|
||||
serialNumber
|
||||
},
|
||||
options
|
||||
);
|
||||
handlePopUpToggle("certificateExport", false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -100,79 +167,89 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Export Certificate">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">Choose the format for exporting your certificate</p>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Choose the format for exporting your certificate
|
||||
</p>
|
||||
|
||||
<FormControl
|
||||
label="Export Format"
|
||||
helperText={
|
||||
selectedFormat === "pem"
|
||||
? "Privacy Enhanced Mail - Text-based certificate format"
|
||||
: "PKCS12 format - Binary keystore format compatible with Java applications"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedFormat}
|
||||
onValueChange={(value) => setSelectedFormat(value as CertificateExportFormat)}
|
||||
>
|
||||
<SelectItem value="pem">PEM Format</SelectItem>
|
||||
<SelectItem value="pkcs12">PKCS12 Format</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedFormat === "pkcs12" && (
|
||||
<>
|
||||
<FormControl
|
||||
label="Keystore Password"
|
||||
helperText={
|
||||
pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6
|
||||
? undefined
|
||||
: "Password to protect the PKCS12 keystore (minimum 6 characters)"
|
||||
}
|
||||
isError={pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6}
|
||||
errorText="Password must be at least 6 characters long"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter keystore password"
|
||||
value={pkcs12Options.password}
|
||||
onChange={(e) =>
|
||||
setPkcs12Options((prev) => ({ ...prev, password: e.target.value }))
|
||||
<Controller
|
||||
control={control}
|
||||
name="format"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Export Format"
|
||||
helperText={
|
||||
field.value === "pem"
|
||||
? "Privacy Enhanced Mail - Text-based certificate format"
|
||||
: "PKCS12 format - Binary keystore format compatible with Java applications"
|
||||
}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select className="w-full" value={field.value} onValueChange={field.onChange}>
|
||||
<SelectItem value="pem">PEM Format</SelectItem>
|
||||
<SelectItem value="pkcs12">PKCS12 Format</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
label="Certificate Alias"
|
||||
helperText="Friendly name for the certificate in the keystore"
|
||||
{selectedFormat === "pkcs12" && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="pkcs12Password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Keystore Password"
|
||||
helperText="Password to protect the PKCS12 keystore (minimum 6 characters)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="Enter keystore password" type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="pkcs12Alias"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Certificate Alias"
|
||||
helperText="Friendly name for the certificate in the keystore"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="Enter certificate alias" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpToggle("certificateExport", false)}
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter certificate alias"
|
||||
value={pkcs12Options.alias}
|
||||
onChange={(e) => setPkcs12Options((prev) => ({ ...prev, alias: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpToggle("certificateExport", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faDownload} />}
|
||||
onClick={handleExport}
|
||||
disabled={!(certificateId || serialNumber) || !isFormValid()}
|
||||
>
|
||||
Export {selectedFormat.toUpperCase()}
|
||||
</Button>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faDownload} />}
|
||||
disabled={!(certificateId || serialNumber)}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Export {selectedFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { HerokuConnectionForm } from "./HerokuAppConnectionForm";
|
||||
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
|
||||
import { LaravelForgeConnectionForm } from "./LaravelForgeConnectionForm";
|
||||
import { LdapConnectionForm } from "./LdapConnectionForm";
|
||||
import { MongoDBConnectionForm } from "./MongoDBConnectionForm";
|
||||
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
|
||||
import { MySqlConnectionForm } from "./MySqlConnectionForm";
|
||||
import { NetlifyConnectionForm } from "./NetlifyConnectionForm";
|
||||
@@ -173,6 +174,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => {
|
||||
return <OktaConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Redis:
|
||||
return <RedisConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.MongoDB:
|
||||
return <MongoDBConnectionForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled App ${app}`);
|
||||
}
|
||||
@@ -331,6 +334,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
return <OktaConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Redis:
|
||||
return <RedisConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.MongoDB:
|
||||
return <MongoDBConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
default:
|
||||
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { MongoDBConnectionMethod, TMongoDBConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type Props = {
|
||||
appConnection?: TMongoDBConnection;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.MongoDB)
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
rootSchema.extend({
|
||||
method: z.literal(MongoDBConnectionMethod.UsernameAndPassword),
|
||||
credentials: z.object({
|
||||
host: z.string().trim().min(1, "Host required"),
|
||||
port: z.coerce.number().default(27017),
|
||||
username: z.string().trim().min(1, "Username required"),
|
||||
password: z.string().trim().min(1, "Password required"),
|
||||
database: z.string().trim().min(1, "Database required"),
|
||||
tlsEnabled: z.boolean().default(false),
|
||||
tlsRejectUnauthorized: z.boolean().default(true),
|
||||
tlsCertificate: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value || undefined)
|
||||
.optional()
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const MongoDBConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.MongoDB,
|
||||
method: MongoDBConnectionMethod.UsernameAndPassword,
|
||||
credentials: {
|
||||
host: "",
|
||||
port: 27017,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "",
|
||||
tlsEnabled: false,
|
||||
tlsRejectUnauthorized: true,
|
||||
tlsCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const tlsEnabled = watch("credentials.tlsEnabled");
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText={`The method you would like to use to connect with ${
|
||||
APP_CONNECTION_MAP[AppConnection.MongoDB].name
|
||||
}. This field cannot be changed after creation.`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(MongoDBConnectionMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} key={method}>
|
||||
{getAppConnectionMethodDetails(method).name}{" "}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
|
||||
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||
: "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Configuration
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||
: "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
TLS ({tlsEnabled ? "Enabled" : "Disabled"})
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
|
||||
<Tab.Panel>
|
||||
<div className="mt-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="credentials.host"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Host"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.database"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Database"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.port"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-28"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Port"
|
||||
>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="credentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.password"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
className="flex-1"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 w-full group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<Controller
|
||||
name="credentials.tlsEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="tls-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
>
|
||||
Enable TLS
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.tlsCertificate"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className={tlsEnabled ? "" : "opacity-50"}
|
||||
label="TLS Certificate"
|
||||
isOptional
|
||||
>
|
||||
<TextArea className="h-14 resize-none!" {...field} isDisabled={!tlsEnabled} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.tlsRejectUnauthorized"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className={tlsEnabled ? "" : "opacity-50"}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="tls-reject-unauthorized"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={tlsEnabled ? value : false}
|
||||
onCheckedChange={onChange}
|
||||
isDisabled={!tlsEnabled}
|
||||
>
|
||||
<p className="w-38">
|
||||
Reject Unauthorized
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<p>
|
||||
If enabled, Infisical will only connect to the server if it has a
|
||||
valid, trusted TLS certificate.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Connect to Database"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
AddExternalKmsType,
|
||||
ExternalKmsProvider,
|
||||
Kms,
|
||||
KmsAwsCredentialType
|
||||
KmsAwsCredentialType,
|
||||
UpdateExternalKmsSchema
|
||||
} from "@app/hooks/api/kms/types";
|
||||
|
||||
const AWS_REGIONS = [
|
||||
@@ -50,9 +51,12 @@ type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
kms?: Kms;
|
||||
mode?: "full" | "credentials" | "details";
|
||||
};
|
||||
|
||||
export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
export const AwsKmsForm = ({ onCompleted, onCancel, kms, mode = "full" }: Props) => {
|
||||
const validationSchema = kms ? UpdateExternalKmsSchema : AddExternalKmsSchema;
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -60,24 +64,35 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<AddExternalKmsType>({
|
||||
resolver: zodResolver(AddExternalKmsSchema),
|
||||
resolver: zodResolver(validationSchema),
|
||||
defaultValues: {
|
||||
name: kms?.name,
|
||||
description: kms?.description ?? "",
|
||||
provider: {
|
||||
configuration: {
|
||||
type: ExternalKmsProvider.Aws,
|
||||
inputs: {
|
||||
credential: {
|
||||
type: kms?.external?.providerInput?.credential?.type,
|
||||
data: {
|
||||
accessKey: kms?.external?.providerInput?.credential?.data?.accessKey,
|
||||
secretKey: kms?.external?.providerInput?.credential?.data?.secretKey,
|
||||
assumeRoleArn: kms?.external?.providerInput?.credential?.data?.assumeRoleArn,
|
||||
externalId: kms?.external?.providerInput?.credential?.data?.externalId
|
||||
}
|
||||
},
|
||||
awsRegion: kms?.external?.providerInput?.awsRegion,
|
||||
kmsKeyId: kms?.external?.providerInput?.kmsKeyId
|
||||
...(mode !== "details" &&
|
||||
kms?.externalKms?.configuration?.credential?.type &&
|
||||
kms.externalKms.configuration.credential.data
|
||||
? {
|
||||
credential: {
|
||||
type: kms.externalKms.configuration.credential.type,
|
||||
data: {
|
||||
accessKey: kms.externalKms.configuration.credential.data?.accessKey ?? "",
|
||||
secretKey: kms.externalKms.configuration.credential.data?.secretKey ?? "",
|
||||
assumeRoleArn:
|
||||
kms.externalKms.configuration.credential.data?.assumeRoleArn ?? "",
|
||||
externalId: kms.externalKms.configuration.credential.data?.externalId ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(mode !== "credentials"
|
||||
? {
|
||||
awsRegion: kms?.externalKms?.configuration?.awsRegion ?? "",
|
||||
kmsKeyId: kms?.externalKms?.configuration?.kmsKeyId ?? ""
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,30 +100,59 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { mutateAsync: addAwsExternalKms } = useAddExternalKms(currentOrg.id);
|
||||
const { mutateAsync: updateAwsExternalKms } = useUpdateExternalKms(currentOrg.id);
|
||||
const { mutateAsync: updateAwsExternalKms } = useUpdateExternalKms(
|
||||
currentOrg.id,
|
||||
ExternalKmsProvider.Aws
|
||||
);
|
||||
|
||||
const selectedAwsAuthType = watch("provider.inputs.credential.type");
|
||||
const selectedAwsAuthType = watch("configuration.inputs.credential.type");
|
||||
|
||||
const handleAwsKmsFormSubmit = async (data: AddExternalKmsType) => {
|
||||
const { name, description, provider } = data;
|
||||
const { name, description, configuration } = data;
|
||||
try {
|
||||
if (kms) {
|
||||
await updateAwsExternalKms({
|
||||
kmsId: kms.id,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
});
|
||||
if (configuration.type !== ExternalKmsProvider.Aws) {
|
||||
throw new Error("Invalid configuration type");
|
||||
}
|
||||
const awsInputs = configuration.inputs;
|
||||
|
||||
if (mode === "credentials") {
|
||||
await updateAwsExternalKms({
|
||||
kmsId: kms.id,
|
||||
configuration: {
|
||||
type: ExternalKmsProvider.Aws,
|
||||
inputs: {
|
||||
credential: { ...awsInputs.credential }
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await updateAwsExternalKms({
|
||||
kmsId: kms.id,
|
||||
name,
|
||||
description,
|
||||
configuration: {
|
||||
type: ExternalKmsProvider.Aws,
|
||||
inputs: {
|
||||
awsRegion: awsInputs.awsRegion,
|
||||
kmsKeyId: awsInputs.kmsKeyId
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated AWS External KMS",
|
||||
text:
|
||||
mode === "credentials"
|
||||
? "Successfully updated AWS External KMS credentials"
|
||||
: "Successfully updated AWS External KMS Details",
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
await addAwsExternalKms({
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
configuration
|
||||
});
|
||||
|
||||
createNotification({
|
||||
@@ -125,104 +169,149 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleAwsKmsFormSubmit)} autoComplete="off">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.credential.type"
|
||||
defaultValue={KmsAwsCredentialType.AssumeRole}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Authentication Mode"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
setValue("provider.inputs.credential.data.accessKey", "");
|
||||
setValue("provider.inputs.credential.data.secretKey", "");
|
||||
setValue("provider.inputs.credential.data.assumeRoleArn", "");
|
||||
setValue("provider.inputs.credential.data.externalId", "");
|
||||
|
||||
onChange(e);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={KmsAwsCredentialType.AssumeRole}>AWS Assume Role</SelectItem>
|
||||
<SelectItem value={KmsAwsCredentialType.AccessKey}>Access Key</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedAwsAuthType === KmsAwsCredentialType.AccessKey ? (
|
||||
{(mode === "full" || mode === "details") && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.credential.data.accessKey"
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Access Key ID"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.credential.data.secretKey"
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Access Key"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input type="password" autoComplete="new-password" placeholder="" {...field} />
|
||||
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{(mode === "full" || mode === "credentials") && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.credential.data.assumeRoleArn"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
name="configuration.inputs.credential.type"
|
||||
defaultValue={KmsAwsCredentialType.AssumeRole}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="IAM Role ARN For Role Assumption"
|
||||
label="Authentication Mode"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input placeholder="" {...field} />
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
setValue("configuration.inputs.credential.data.accessKey", "");
|
||||
setValue("configuration.inputs.credential.data.secretKey", "");
|
||||
setValue("configuration.inputs.credential.data.assumeRoleArn", "");
|
||||
setValue("configuration.inputs.credential.data.externalId", "");
|
||||
|
||||
onChange(e);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={KmsAwsCredentialType.AssumeRole}>AWS Assume Role</SelectItem>
|
||||
<SelectItem value={KmsAwsCredentialType.AccessKey}>Access Key</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedAwsAuthType === KmsAwsCredentialType.AccessKey ? (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="configuration.inputs.credential.data.accessKey"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Access Key ID"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="configuration.inputs.credential.data.secretKey"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Access Key"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input type="password" autoComplete="new-password" placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="configuration.inputs.credential.data.assumeRoleArn"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="IAM Role ARN For Role Assumption"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="configuration.inputs.credential.data.externalId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Assume Role External ID"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(mode === "full" || mode === "details") && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="configuration.inputs.awsRegion"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="AWS Region" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{AWS_REGIONS.map((awsRegion) => (
|
||||
<SelectItem value={awsRegion.slug} key={`kms-aws-region-${awsRegion.slug}`}>
|
||||
{awsRegion.name} <Badge variant="neutral">{awsRegion.slug}</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.credential.data.externalId"
|
||||
name="configuration.inputs.kmsKeyId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Assume Role External ID"
|
||||
label="AWS KMS Key ID"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
@@ -232,38 +321,9 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.awsRegion"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="AWS Region" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{AWS_REGIONS.map((awsRegion) => (
|
||||
<SelectItem value={awsRegion.slug} key={`kms-aws-region-${awsRegion.slug}`}>
|
||||
{awsRegion.name} <Badge variant="neutral">{awsRegion.slug}</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.inputs.kmsKeyId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="AWS KMS Key ID" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
{mode === "credentials" ? "Update Credentials" : "Save"}
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ContentLoader, Modal, ModalContent } from "@app/components/v2";
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { useGetExternalKmsById } from "@app/hooks/api";
|
||||
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
|
||||
|
||||
@@ -8,25 +8,36 @@ import { GcpKmsForm } from "./GcpKmsForm";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
kmsId: string;
|
||||
provider: ExternalKmsProvider;
|
||||
onOpenChange: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export const UpdateExternalKmsForm = ({ isOpen, kmsId, onOpenChange }: Props) => {
|
||||
const { data: externalKms, isPending } = useGetExternalKmsById(kmsId);
|
||||
export const EditExternalKmsCredentialsModal = ({
|
||||
isOpen,
|
||||
kmsId,
|
||||
provider,
|
||||
onOpenChange
|
||||
}: Props) => {
|
||||
const { data: kms } = useGetExternalKmsById({ kmsId, provider });
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Edit configuration" bodyClassName="overflow-visible">
|
||||
{isPending && <ContentLoader />}
|
||||
{externalKms?.external?.provider === ExternalKmsProvider.Aws && (
|
||||
<ModalContent
|
||||
title="Edit Credentials"
|
||||
subTitle="Update the credentials for this KMS."
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
{kms?.externalKms?.provider === ExternalKmsProvider.Aws && (
|
||||
<AwsKmsForm
|
||||
kms={externalKms}
|
||||
kms={kms}
|
||||
mode="credentials"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCompleted={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
{externalKms?.external?.provider === ExternalKmsProvider.Gcp && (
|
||||
{kms?.externalKms?.provider === ExternalKmsProvider.Gcp && (
|
||||
<GcpKmsForm
|
||||
kms={externalKms}
|
||||
kms={kms}
|
||||
mode="credentials"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCompleted={() => onOpenChange(false)}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { useGetExternalKmsById } from "@app/hooks/api";
|
||||
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
|
||||
|
||||
import { AwsKmsForm } from "./AwsKmsForm";
|
||||
import { GcpKmsForm } from "./GcpKmsForm";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
kmsId: string;
|
||||
provider: ExternalKmsProvider;
|
||||
};
|
||||
|
||||
export const EditExternalKmsDetailsModal = ({ isOpen, onOpenChange, kmsId, provider }: Props) => {
|
||||
const { data: kms } = useGetExternalKmsById({ kmsId, provider });
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Edit KMS Details"
|
||||
subTitle="Update the name and description for this KMS."
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
{kms?.externalKms?.provider === ExternalKmsProvider.Aws && (
|
||||
<AwsKmsForm
|
||||
kms={kms}
|
||||
mode="details"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCompleted={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
{kms?.externalKms?.provider === ExternalKmsProvider.Gcp && (
|
||||
<GcpKmsForm
|
||||
kms={kms}
|
||||
mode="details"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCompleted={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,9 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
type Props = {
|
||||
kms: KmsListEntry;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["editExternalKms", "removeExternalKms", "upgradePlan"]>,
|
||||
popUpName: keyof UsePopUpState<
|
||||
["editExternalKmsDetails", "editExternalKmsCredentials", "removeExternalKms", "upgradePlan"]
|
||||
>,
|
||||
data?: {
|
||||
kmsId?: string;
|
||||
name?: string;
|
||||
@@ -104,27 +106,52 @@ export const ExternalKmsItem = ({ kms, handlePopUpOpen, subscription }: Props) =
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} an={OrgPermissionSubjects.Kms}>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
disabled={!isAllowed}
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (subscription && !subscription?.externalKms) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={!isAllowed}
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (subscription && !subscription?.externalKms) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("editExternalKms", {
|
||||
kmsId: kms.id
|
||||
});
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
handlePopUpOpen("editExternalKmsDetails", {
|
||||
kmsId: kms.id,
|
||||
provider: kms.externalKms.provider
|
||||
});
|
||||
}}
|
||||
>
|
||||
Edit Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!isAllowed}
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (subscription && !subscription?.externalKms) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("editExternalKmsCredentials", {
|
||||
kmsId: kms.id,
|
||||
provider: kms.externalKms.provider
|
||||
});
|
||||
}}
|
||||
>
|
||||
Edit Credentials
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Delete} an={OrgPermissionSubjects.Kms}>
|
||||
|
||||
@@ -24,6 +24,7 @@ type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
kms?: Kms;
|
||||
mode?: "full" | "credentials" | "details";
|
||||
};
|
||||
|
||||
const GCP_REGIONS = [
|
||||
@@ -76,7 +77,7 @@ const formatOptionLabel = ({ value, label }: { value: string; label: string }) =
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
export const GcpKmsForm = ({ onCompleted, onCancel, kms, mode = "full" }: Props) => {
|
||||
const [isCredentialValid, setIsCredentialValid] = useState<boolean>(false);
|
||||
const [keys, setKeys] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
@@ -88,7 +89,7 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
getValues,
|
||||
resetField,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
formState: { isSubmitting, isDirty, isValid }
|
||||
} = useForm<AddExternalKmsGcpFormSchemaType>({
|
||||
resolver: zodResolver(AddExternalKmsGcpFormSchema),
|
||||
defaultValues: {
|
||||
@@ -98,9 +99,9 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
gcpRegion: kms
|
||||
? {
|
||||
label:
|
||||
GCP_REGIONS.find((r) => r.value === kms.external.providerInput.gcpRegion)?.label ??
|
||||
GCP_REGIONS.find((r) => r.value === kms.externalKms.configuration.gcpRegion)?.label ??
|
||||
"",
|
||||
value: kms.external.providerInput.gcpRegion
|
||||
value: kms.externalKms.configuration.gcpRegion
|
||||
}
|
||||
: undefined,
|
||||
keyObject: undefined
|
||||
@@ -109,7 +110,11 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { mutateAsync: addGcpExternalKms } = useAddExternalKms(currentOrg.id);
|
||||
const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms(currentOrg.id);
|
||||
const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms(
|
||||
currentOrg.id,
|
||||
ExternalKmsProvider.Gcp
|
||||
);
|
||||
|
||||
const { mutateAsync: fetchGcpKeys, isPending: isFetchGcpKeysLoading } =
|
||||
useExternalKmsFetchGcpKeys(currentOrg?.id);
|
||||
|
||||
@@ -140,36 +145,62 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
// handles the form submission
|
||||
const handleGcpKmsFormSubmit = async (data: AddExternalKmsGcpFormSchemaType) => {
|
||||
const { name, description, gcpRegion: gcpRegionObject, keyObject } = data;
|
||||
const gcpRegion = gcpRegionObject.value;
|
||||
if (!keys.find((k) => k.value === keyObject?.value)) {
|
||||
setError("keyObject", {
|
||||
message: "Please select a valid key."
|
||||
});
|
||||
resetField("keyObject");
|
||||
return;
|
||||
}
|
||||
const { name, description, formType, gcpRegion: gcpRegionObject, keyObject } = data;
|
||||
|
||||
try {
|
||||
if (kms) {
|
||||
await updateGcpExternalKms({
|
||||
kmsId: kms.id,
|
||||
name,
|
||||
description,
|
||||
provider: {
|
||||
type: ExternalKmsProvider.Gcp,
|
||||
inputs: {
|
||||
gcpRegion,
|
||||
keyName: keyObject?.value
|
||||
}
|
||||
if (formType === "updateGcpKms") {
|
||||
const gcpRegion = gcpRegionObject?.value;
|
||||
if (!gcpRegion) {
|
||||
setError("gcpRegion", {
|
||||
message: "Please select a GCP region."
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated GCP External KMS",
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
if (keyObject && !keys.find((k) => k.value === keyObject.value)) {
|
||||
setError("keyObject", {
|
||||
message: "Please select a valid key."
|
||||
});
|
||||
resetField("keyObject");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateGcpExternalKms({
|
||||
kmsId: kms.id,
|
||||
name,
|
||||
description,
|
||||
configuration: {
|
||||
type: ExternalKmsProvider.Gcp,
|
||||
inputs: {
|
||||
gcpRegion,
|
||||
keyName: keyObject?.value ?? kms.externalKms.configuration.keyName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated GCP External KMS Details",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
} else if (formType === "newGcpKms") {
|
||||
const gcpRegion = gcpRegionObject?.value;
|
||||
if (!gcpRegion) {
|
||||
setError("gcpRegion", {
|
||||
message: "Please select a GCP region."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keys.find((k) => k.value === keyObject?.value)) {
|
||||
setError("keyObject", {
|
||||
message: "Please select a valid key."
|
||||
});
|
||||
resetField("keyObject");
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialJson = await getCredentialFileJson();
|
||||
if (!credentialJson) {
|
||||
return;
|
||||
@@ -177,11 +208,11 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
await addGcpExternalKms({
|
||||
name,
|
||||
description,
|
||||
provider: {
|
||||
configuration: {
|
||||
type: ExternalKmsProvider.Gcp,
|
||||
inputs: {
|
||||
gcpRegion,
|
||||
keyName: keyObject?.value,
|
||||
keyName: keyObject?.value ?? "",
|
||||
credential: credentialJson
|
||||
}
|
||||
}
|
||||
@@ -208,8 +239,9 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
if (!kms && !credentialJson) {
|
||||
return;
|
||||
}
|
||||
const gcpRegion = getValues("gcpRegion").value;
|
||||
if (!gcpRegion.length) {
|
||||
const gcpRegionObject = getValues("gcpRegion");
|
||||
const gcpRegion = gcpRegionObject?.value;
|
||||
if (!gcpRegion) {
|
||||
setError("gcpRegion", {
|
||||
message: "Please select a GCP region to fetch GCP Keys."
|
||||
});
|
||||
@@ -231,7 +263,9 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
setKeys(returnedKeys);
|
||||
if (kms) {
|
||||
const existingKey = returnedKeys.find((k) => k.value === kms.external.providerInput.keyName);
|
||||
const existingKey = returnedKeys.find(
|
||||
(k) => k.value === kms.externalKms.configuration.keyName
|
||||
);
|
||||
if (existingKey) {
|
||||
setValue("keyObject", existingKey);
|
||||
}
|
||||
@@ -260,96 +294,104 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleGcpKmsFormSubmit)} autoComplete="off">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="gcpRegion"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="GCP Region" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder="Select a GCP region"
|
||||
name="gcpRegion"
|
||||
options={GCP_REGIONS}
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
resetField("keyObject");
|
||||
field.onChange(e);
|
||||
fetchGCPKeys();
|
||||
}}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
{(mode === "full" || mode === "details") && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="gcpRegion"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="GCP Region" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder="Select a GCP region"
|
||||
name="gcpRegion"
|
||||
options={GCP_REGIONS}
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
resetField("keyObject");
|
||||
field.onChange(e);
|
||||
fetchGCPKeys();
|
||||
}}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!kms && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="credentialFile"
|
||||
render={({ field: { value, onChange, ref, ...rest }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Credential JSON"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept=".json"
|
||||
placeholder=""
|
||||
value={value?.filename}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.files);
|
||||
fetchGCPKeys();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!kms && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="credentialFile"
|
||||
render={({ field: { value, onChange, ref, ...rest }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Credential JSON"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept=".json"
|
||||
placeholder=""
|
||||
value={value?.filename}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.files);
|
||||
fetchGCPKeys();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyObject"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="GCP Key Name" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder={getPlaceholderText()}
|
||||
isDisabled={!isCredentialValid || !keys.length}
|
||||
name="key"
|
||||
options={keys}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyObject"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="GCP Key Name" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder={getPlaceholderText()}
|
||||
isDisabled={!isCredentialValid || !keys.length}
|
||||
name="key"
|
||||
options={keys}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{kms && (
|
||||
{kms && mode === "credentials" && (
|
||||
<span className="text-xs text-mineshaft-300">
|
||||
To change your GCP credentials, create a new external KMS and assign it to project you
|
||||
want to use it with.
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-6 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty || !isValid || mode === "credentials"}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetExternalKmsList, useRemoveExternalKms } from "@app/hooks/api";
|
||||
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
|
||||
|
||||
import { AddExternalKmsForm } from "./AddExternalKmsForm";
|
||||
import { EditExternalKmsCredentialsModal } from "./EditExternalKmsCredentialsModal";
|
||||
import { EditExternalKmsDetailsModal } from "./EditExternalKmsDetailsModal";
|
||||
import { ExternalKmsItem } from "./ExternalKmsItem";
|
||||
import { UpdateExternalKmsForm } from "./UpdateExternalKmsForm";
|
||||
|
||||
export const OrgEncryptionTab = withPermission(
|
||||
() => {
|
||||
@@ -38,7 +40,8 @@ export const OrgEncryptionTab = withPermission(
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"upgradePlan",
|
||||
"addExternalKms",
|
||||
"editExternalKms",
|
||||
"editExternalKmsDetails",
|
||||
"editExternalKmsCredentials",
|
||||
"removeExternalKms"
|
||||
] as const);
|
||||
const { data: externalKmsList, isPending: isExternalKmsListLoading } =
|
||||
@@ -47,11 +50,12 @@ export const OrgEncryptionTab = withPermission(
|
||||
const { mutateAsync: removeExternalKms } = useRemoveExternalKms(currentOrg.id);
|
||||
|
||||
const handleRemoveExternalKms = async () => {
|
||||
const { kmsId } = popUp?.removeExternalKms?.data as {
|
||||
const { kmsId, provider } = popUp?.removeExternalKms?.data as {
|
||||
kmsId: string;
|
||||
provider: ExternalKmsProvider;
|
||||
};
|
||||
|
||||
await removeExternalKms(kmsId);
|
||||
await removeExternalKms({ kmsId, provider });
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted external KMS",
|
||||
@@ -128,10 +132,21 @@ export const OrgEncryptionTab = withPermission(
|
||||
isOpen={popUp.addExternalKms.isOpen}
|
||||
onToggle={(state) => handlePopUpToggle("addExternalKms", state)}
|
||||
/>
|
||||
<UpdateExternalKmsForm
|
||||
isOpen={popUp.editExternalKms.isOpen}
|
||||
kmsId={(popUp.editExternalKms.data as { kmsId: string })?.kmsId}
|
||||
onOpenChange={(state) => handlePopUpToggle("editExternalKms", state)}
|
||||
<EditExternalKmsDetailsModal
|
||||
isOpen={popUp.editExternalKmsDetails.isOpen}
|
||||
kmsId={(popUp.editExternalKmsDetails.data as { kmsId: string })?.kmsId}
|
||||
provider={
|
||||
(popUp.editExternalKmsDetails.data as { provider: ExternalKmsProvider })?.provider
|
||||
}
|
||||
onOpenChange={(state) => handlePopUpToggle("editExternalKmsDetails", state)}
|
||||
/>
|
||||
<EditExternalKmsCredentialsModal
|
||||
isOpen={popUp.editExternalKmsCredentials.isOpen}
|
||||
kmsId={(popUp.editExternalKmsCredentials.data as { kmsId: string })?.kmsId}
|
||||
provider={
|
||||
(popUp.editExternalKmsCredentials.data as { provider: ExternalKmsProvider })?.provider
|
||||
}
|
||||
onOpenChange={(state) => handlePopUpToggle("editExternalKmsCredentials", state)}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeExternalKms.isOpen}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { faBan, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
ConfirmActionModal,
|
||||
ContentLoader,
|
||||
EmptyState,
|
||||
PageHeader
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useOrganization, useProject, useUser } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||
import {
|
||||
approvalRequestQuery,
|
||||
ApprovalRequestStatus,
|
||||
useCancelApprovalRequest
|
||||
} from "@app/hooks/api/approvalRequests";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
|
||||
import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components";
|
||||
|
||||
const PageContent = () => {
|
||||
const { approvalRequestId } = useParams({
|
||||
from: ROUTE_PATHS.Pam.ApprovalRequestDetailPage.id
|
||||
});
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
const { user: currentUser } = useUser();
|
||||
const cancelApprovalRequest = useCancelApprovalRequest();
|
||||
const navigate = useNavigate();
|
||||
const { handlePopUpOpen, handlePopUpToggle, popUp } = usePopUp(["cancelRequest"]);
|
||||
|
||||
const { data: request, isPending } = useQuery(
|
||||
approvalRequestQuery.getById({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
requestId: approvalRequestId
|
||||
})
|
||||
);
|
||||
|
||||
const handleRequestCancel = async () => {
|
||||
if (cancelApprovalRequest.isPending || !request) return;
|
||||
|
||||
await cancelApprovalRequest.mutateAsync(
|
||||
{
|
||||
requestId: request.id,
|
||||
policyType: ApprovalPolicyType.PamAccess
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
createNotification({
|
||||
text: "Successfully cancelled request",
|
||||
type: "success"
|
||||
});
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects/pam/$projectId/approvals",
|
||||
params: {
|
||||
orgId: currentProject.orgId,
|
||||
projectId: currentProject.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-20">
|
||||
<EmptyState
|
||||
className="max-w-2xl rounded-md text-center"
|
||||
icon={faBan}
|
||||
title={`Could not find approval request with ID ${approvalRequestId}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-8xl">
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/approvals"
|
||||
params={{ orgId: currentOrg.id, projectId: currentProject.id }}
|
||||
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Approvals List
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope={ProjectType.PAM}
|
||||
title="Approval Request"
|
||||
description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.accessDuration} by ${request.requesterName || "Unknown"}`}
|
||||
>
|
||||
<div>
|
||||
{request.requesterId === currentUser.id &&
|
||||
request.status === ApprovalRequestStatus.Pending && (
|
||||
<Button
|
||||
onClick={() => handlePopUpOpen("cancelRequest")}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isLoading={cancelApprovalRequest.isPending}
|
||||
>
|
||||
Cancel Request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="flex w-96 flex-col gap-4">
|
||||
<RequestDetailsSection request={request} />
|
||||
<RequestActionsSection request={request} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<ApprovalStepsSection request={request} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmActionModal
|
||||
isOpen={popUp.cancelRequest.isOpen}
|
||||
confirmKey="cancel"
|
||||
title="Do you want to cancel this approval request?"
|
||||
onChange={(isOpen) => handlePopUpToggle("cancelRequest", isOpen)}
|
||||
onConfirmed={handleRequestCancel}
|
||||
buttonText="Confirm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApprovalRequestDetailPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Approval Request | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<PageContent />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMemo } from "react";
|
||||
import { faCheck, faCheckCircle, faClock, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { User, Users } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useProject } from "@app/context";
|
||||
import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import { ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||
import {
|
||||
ApprovalRequestApproval,
|
||||
ApprovalRequestStepStatus,
|
||||
TApprovalRequest
|
||||
} from "@app/hooks/api/approvalRequests";
|
||||
import { ApprovalRequestApprovalDecision } from "@app/hooks/api/approvalRequests/types";
|
||||
|
||||
type Props = {
|
||||
request: TApprovalRequest;
|
||||
};
|
||||
|
||||
const getMemberLabel = (member: {
|
||||
user: { username: string; email: string; firstName: string; lastName: string };
|
||||
}) => {
|
||||
const { user } = member;
|
||||
if (user.firstName || user.lastName) {
|
||||
return `${user.firstName || ""} ${user.lastName || ""}`.trim();
|
||||
}
|
||||
return user.username || user.email;
|
||||
};
|
||||
|
||||
export const ApprovalStepsSection = ({ request }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.id || "";
|
||||
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId, true);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const getApproverLabel = useMemo(
|
||||
() => (approverId: string, approverType: ApproverType) => {
|
||||
if (approverType === ApproverType.User) {
|
||||
const member = members?.find((m) => m.user.id === approverId);
|
||||
if (member) return getMemberLabel(member);
|
||||
} else if (approverType === ApproverType.Group) {
|
||||
const group = groups?.find(({ group: g }) => g.id === approverId);
|
||||
if (group) return group.group.name;
|
||||
}
|
||||
return approverId;
|
||||
},
|
||||
[members, groups]
|
||||
);
|
||||
|
||||
const getApprovalLabel = (approval: ApprovalRequestApproval) => {
|
||||
const member = members?.find((m) => m.user.id === approval.approverUserId);
|
||||
if (member) return getMemberLabel(member);
|
||||
return approval.approverUserId;
|
||||
};
|
||||
|
||||
const getStepStatusIcon = (status: ApprovalRequestStepStatus) => {
|
||||
switch (status) {
|
||||
case ApprovalRequestStepStatus.Approved:
|
||||
return <FontAwesomeIcon icon={faCheckCircle} className="text-green-500" />;
|
||||
case ApprovalRequestStepStatus.InProgress:
|
||||
return <FontAwesomeIcon icon={faClock} className="text-yellow-500" />;
|
||||
case ApprovalRequestStepStatus.Pending:
|
||||
return <div className="h-4 w-4 rounded-full border-2 border-mineshaft-400" />;
|
||||
case ApprovalRequestStepStatus.Rejected:
|
||||
return <FontAwesomeIcon icon={faXmark} className="text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between border-b border-mineshaft-500 pb-2">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Approval Workflow</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{request.steps.map((step, index) => (
|
||||
<div key={step.id} className="relative">
|
||||
{index < request.steps.length - 1 && (
|
||||
<div className="absolute top-8 left-2 h-full w-0.5 bg-mineshaft-600" />
|
||||
)}
|
||||
<div className="relative flex gap-3">
|
||||
<div className="relative top-3 z-10 flex h-4 w-4 items-center justify-center bg-mineshaft-900">
|
||||
{getStepStatusIcon(step.status)}
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded-lg border bg-mineshaft-800",
|
||||
step.status === ApprovalRequestStepStatus.InProgress
|
||||
? "border-yellow-500/40"
|
||||
: "border-mineshaft-600"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mb-2 flex items-center justify-between p-2",
|
||||
step.status === ApprovalRequestStepStatus.InProgress
|
||||
? "bg-yellow-500/20"
|
||||
: "bg-mineshaft-700"
|
||||
)}
|
||||
>
|
||||
<h4 className="font-medium text-mineshaft-100">
|
||||
Step {index + 1}
|
||||
{step.status === ApprovalRequestStepStatus.InProgress && (
|
||||
<span className="ml-2 text-xs text-yellow-500">(Current Step)</span>
|
||||
)}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={
|
||||
step.status === ApprovalRequestStepStatus.InProgress ? "project" : "neutral"
|
||||
}
|
||||
className="capitalize"
|
||||
>
|
||||
{step.status.split("-").join(" ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3 p-2 pb-4 text-bunker-200">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div>Approvals Required:</div>
|
||||
<div>{step.requiredApprovals}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="mb-2">Approvers</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{step.approvers.map((approver) => (
|
||||
<Badge variant="neutral">
|
||||
{approver.type === ApproverType.User ? <User /> : <Users />}
|
||||
{getApproverLabel(approver.id, approver.type)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{step.approvals.length > 0 && (
|
||||
<div className="mt-3 space-y-2 border-t border-mineshaft-600 pt-3">
|
||||
<p className="text-xs font-medium text-mineshaft-300">Approvals Given:</p>
|
||||
{step.approvals.map((approval) => (
|
||||
<div
|
||||
key={approval.id}
|
||||
className="flex items-start justify-between rounded bg-mineshaft-900/50 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
approval.decision === ApprovalRequestApprovalDecision.Approved
|
||||
? faCheck
|
||||
: faXmark
|
||||
}
|
||||
className={
|
||||
approval.decision === ApprovalRequestApprovalDecision.Approved
|
||||
? "text-green-500"
|
||||
: "text-red-500"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-mineshaft-100">
|
||||
{getApprovalLabel(approval)}
|
||||
</p>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
{format(new Date(approval.createdAt), "MMM dd, yyyy hh:mm aaa")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{approval.comment && (
|
||||
<p className="max-w-xs text-xs text-mineshaft-400 italic">
|
||||
"{approval.comment}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState } from "react";
|
||||
import { faCheck, faMagnifyingGlass, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormLabel,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useProjectPermission, useUser } from "@app/context";
|
||||
import { ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||
import {
|
||||
ApprovalRequestStatus,
|
||||
ApprovalRequestStepStatus,
|
||||
TApprovalRequest,
|
||||
useApproveApprovalRequest,
|
||||
useRejectApprovalRequest
|
||||
} from "@app/hooks/api/approvalRequests";
|
||||
|
||||
type Props = {
|
||||
request: TApprovalRequest;
|
||||
};
|
||||
|
||||
export const RequestActionsSection = ({ request }: Props) => {
|
||||
const { user } = useUser();
|
||||
const { memberships } = useProjectPermission();
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const userId = user?.id || "";
|
||||
const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean);
|
||||
|
||||
const { mutateAsync: approveRequest, isPending: isApproving } = useApproveApprovalRequest();
|
||||
const { mutateAsync: rejectRequest, isPending: isRejecting } = useRejectApprovalRequest();
|
||||
|
||||
// Check if the request is actionable
|
||||
if (request.status !== ApprovalRequestStatus.Pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the current active step
|
||||
const currentStep = request.steps.find(
|
||||
(step) => step.status === ApprovalRequestStepStatus.InProgress
|
||||
);
|
||||
|
||||
if (!currentStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user is an approver in the current step
|
||||
const isApprover = currentStep.approvers.some((approver) =>
|
||||
approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id)
|
||||
);
|
||||
|
||||
if (!isApprover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user has already approved/rejected
|
||||
const hasAlreadyActed = currentStep.approvals.some(
|
||||
(approval) => approval.approverUserId === userId
|
||||
);
|
||||
|
||||
if (hasAlreadyActed) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
You have already provided your approval for this request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await approveRequest({
|
||||
policyType: request.type,
|
||||
requestId: request.id,
|
||||
comment: comment || undefined
|
||||
});
|
||||
createNotification({
|
||||
text: "Request approved successfully",
|
||||
type: "success"
|
||||
});
|
||||
} finally {
|
||||
setComment("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
await rejectRequest({
|
||||
policyType: request.type,
|
||||
requestId: request.id,
|
||||
comment: comment || undefined
|
||||
});
|
||||
createNotification({
|
||||
text: "Request rejected successfully",
|
||||
type: "success"
|
||||
});
|
||||
} finally {
|
||||
setComment("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-yellow-500/30 pb-2">
|
||||
<h3 className="font-medium text-mineshaft-100">Action Required</h3>
|
||||
<span className="text-xs text-yellow-500">Awaiting Your Approval</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
You are an approver for the current step. Please review the request details and provide
|
||||
your decision.
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" title="Finish your review" className="w-96">
|
||||
<FormLabel label="Finish your review" />
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Add a comment about your decision..."
|
||||
rows={3}
|
||||
reSize="vertical"
|
||||
className="mt-1 mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
onClick={handleApprove}
|
||||
isLoading={isApproving}
|
||||
isDisabled={isRejecting}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Approve Request
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faXmark} />}
|
||||
onClick={handleReject}
|
||||
isLoading={isRejecting}
|
||||
isDisabled={isApproving}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Reject Request
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { GenericFieldLabel } from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { ApprovalRequestStatus, TApprovalRequest } from "@app/hooks/api/approvalRequests";
|
||||
|
||||
type Props = {
|
||||
request: TApprovalRequest;
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
|
||||
switch (status) {
|
||||
case ApprovalRequestStatus.Pending:
|
||||
return "project";
|
||||
case ApprovalRequestStatus.Approved:
|
||||
return "success";
|
||||
case ApprovalRequestStatus.Rejected:
|
||||
return "danger";
|
||||
case ApprovalRequestStatus.Expired:
|
||||
return "neutral";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
export const RequestDetailsSection = ({ request }: Props) => {
|
||||
const { accountPath, accessDuration } = request.requestData.requestData;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-500 pb-2">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Request Details</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<GenericFieldLabel label="Status">
|
||||
<Badge className="capitalize" variant={getStatusBadgeColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Requester Name">
|
||||
{request.requesterName || "Unknown"}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Requester Email">{request.requesterEmail}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Account Path">{accountPath}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Duration">{accessDuration}</GenericFieldLabel>
|
||||
{request.justification && (
|
||||
<GenericFieldLabel label="Justification">
|
||||
<p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">
|
||||
{request.justification}
|
||||
</p>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
<GenericFieldLabel label="Requested At">
|
||||
{format(new Date(request.createdAt), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</GenericFieldLabel>
|
||||
{request.expiresAt && (
|
||||
<GenericFieldLabel label="Expires">
|
||||
{format(new Date(request.expiresAt), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ApprovalStepsSection } from "./ApprovalStepsSection";
|
||||
export { RequestActionsSection } from "./RequestActionsSection";
|
||||
export { RequestDetailsSection } from "./RequestDetailsSection";
|
||||
19
frontend/src/pages/pam/ApprovalRequestDetailPage/route.tsx
Normal file
19
frontend/src/pages/pam/ApprovalRequestDetailPage/route.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { ApprovalRequestDetailPage } from "./ApprovalRequestDetailPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId"
|
||||
)({
|
||||
component: ApprovalRequestDetailPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Approval Request"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
76
frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx
Normal file
76
frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { ApprovalControlTabs } from "@app/types/project";
|
||||
|
||||
import { ApprovalRequestTab } from "./components/ApprovalRequestTab";
|
||||
import { PolicyTab } from "./components/PolicyTab";
|
||||
import { RequestGrantTab } from "./components/RequestGrantTab";
|
||||
|
||||
const Page = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
const selectedTab = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el.selectedTab
|
||||
});
|
||||
|
||||
const updateSelectedTab = (tab: string) => {
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects/pam/$projectId/approvals",
|
||||
search: (prev) => ({ ...prev, selectedTab: tab }),
|
||||
params: {
|
||||
orgId: currentOrg.id,
|
||||
projectId: currentProject.id
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope={currentProject.type}
|
||||
title="PAM Approvals"
|
||||
description="Manage approval workflows, update policy settings, and monitor request statuses."
|
||||
/>
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
<TabList>
|
||||
<Tab variant="project" value={ApprovalControlTabs.Requests}>
|
||||
Requests
|
||||
</Tab>
|
||||
<Tab variant="project" value={ApprovalControlTabs.Policies}>
|
||||
Policies
|
||||
</Tab>
|
||||
<Tab variant="project" value={ApprovalControlTabs.Grants}>
|
||||
Grants
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={ApprovalControlTabs.Requests}>
|
||||
<ApprovalRequestTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={ApprovalControlTabs.Policies}>
|
||||
<PolicyTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={ApprovalControlTabs.Grants}>
|
||||
<RequestGrantTab />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApprovalsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<Page />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faExclamationCircle,
|
||||
faFileCircleQuestion,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useOrganization, useProject, useProjectPermission, useUser } from "@app/context";
|
||||
import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||
import {
|
||||
approvalRequestQuery,
|
||||
ApprovalRequestStatus,
|
||||
ApprovalRequestStepStatus,
|
||||
TApprovalRequest
|
||||
} from "@app/hooks/api/approvalRequests";
|
||||
|
||||
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
|
||||
switch (status) {
|
||||
case ApprovalRequestStatus.Pending:
|
||||
return "project";
|
||||
case ApprovalRequestStatus.Approved:
|
||||
return "success";
|
||||
case ApprovalRequestStatus.Rejected:
|
||||
return "danger";
|
||||
case ApprovalRequestStatus.Expired:
|
||||
return "neutral";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfUserNeedsToApprove = (
|
||||
request: TApprovalRequest,
|
||||
userId: string,
|
||||
userGroups: string[]
|
||||
): boolean => {
|
||||
const currentStep = request.steps.find(
|
||||
(step) => step.status === ApprovalRequestStepStatus.InProgress
|
||||
);
|
||||
|
||||
if (!currentStep) return false;
|
||||
|
||||
const isApprover = currentStep.approvers.some((approver) =>
|
||||
approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id)
|
||||
);
|
||||
|
||||
if (!isApprover) return false;
|
||||
|
||||
const hasAlreadyApproved = currentStep.approvals.some(
|
||||
(approval) => approval.approverUserId === userId
|
||||
);
|
||||
|
||||
return !hasAlreadyApproved;
|
||||
};
|
||||
|
||||
export const ApprovalRequestTab = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
const { memberships } = useProjectPermission();
|
||||
const { user } = useUser();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState(ApprovalRequestStatus.Pending);
|
||||
|
||||
const projectId = currentProject?.id || "";
|
||||
const userId = user?.id || "";
|
||||
const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean);
|
||||
|
||||
const { data: requests = [], isPending: isRequestsLoading } = useQuery(
|
||||
approvalRequestQuery.list({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
projectId
|
||||
})
|
||||
);
|
||||
|
||||
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
||||
initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10)
|
||||
});
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
let filtered = requests;
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(request) =>
|
||||
request.requesterName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
request.requesterEmail?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
request.justification?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
request.requestData.requestData.accountPath.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered
|
||||
.filter((req) => req.status === filter)
|
||||
.sort(
|
||||
(a, b) => (new Date(b.createdAt)?.getTime() || 0) - (new Date(a.createdAt)?.getTime() || 0)
|
||||
);
|
||||
}, [requests, search, filter]);
|
||||
|
||||
const paginatedRequests = useMemo(
|
||||
() => filteredRequests.slice(offset, offset + perPage),
|
||||
[filteredRequests, offset, perPage]
|
||||
);
|
||||
|
||||
const handleRowClick = (requestId: string) => {
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId",
|
||||
params: {
|
||||
orgId: currentOrg.id,
|
||||
projectId: currentProject.id,
|
||||
approvalRequestId: requestId
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isTableFiltered = filter !== ApprovalRequestStatus.Pending;
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Approval Requests</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Requests"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-9.5 w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalRequestStatus.Pending);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalRequestStatus.Pending && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Open Requests
|
||||
</DropdownMenuItem>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Closed Requests
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Filter by Status
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalRequestStatus.Approved);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalRequestStatus.Approved && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Approved
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalRequestStatus.Cancelled);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalRequestStatus.Cancelled && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Cancelled
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalRequestStatus.Rejected);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalRequestStatus.Rejected && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Rejected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalRequestStatus.Expired);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalRequestStatus.Expired && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Expired
|
||||
</DropdownMenuItem>
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search approval requests..."
|
||||
/>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Requester</Th>
|
||||
<Th>Account Path</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Requested</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRequestsLoading && <TableSkeleton columns={5} innerKey="approval-requests" />}
|
||||
{!isRequestsLoading &&
|
||||
paginatedRequests.map((request) => {
|
||||
const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups);
|
||||
const { accountPath, accessDuration } = request.requestData.requestData;
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={request.id}
|
||||
className={twMerge(
|
||||
"cursor-pointer transition-colors hover:bg-mineshaft-700",
|
||||
needsApproval && "bg-primary/5 hover:bg-primary/10"
|
||||
)}
|
||||
onClick={() => handleRowClick(request.id)}
|
||||
>
|
||||
<Td>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-mineshaft-100">
|
||||
{request.requesterName || "Unknown"}
|
||||
</div>
|
||||
<div className="text-xs text-mineshaft-400">{request.requesterEmail}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div>
|
||||
<div className="text-sm text-mineshaft-200">{accountPath}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-200">{accessDuration}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={getStatusBadgeColor(request.status)}
|
||||
>
|
||||
{request.status.split("-").join(" ")}
|
||||
</Badge>
|
||||
{needsApproval && (
|
||||
<div className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} className="h-3 w-3" />
|
||||
<span>Approval Required</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
{formatDistance(new Date(request.createdAt), new Date(), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredRequests.length) && (
|
||||
<Pagination
|
||||
count={filteredRequests.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isRequestsLoading && !filteredRequests?.length && (
|
||||
<EmptyState
|
||||
title="No approval requests found"
|
||||
icon={requests.length ? faSearch : faFileCircleQuestion}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalRequestTab } from "./ApprovalRequestTab";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { ApprovalPolicyType, useDeleteApprovalPolicy } from "@app/hooks/api/approvalPolicies";
|
||||
|
||||
import { PoliciesTable } from "./components/PoliciesTable";
|
||||
import { PolicyModal } from "./components/PolicyModal";
|
||||
|
||||
export const PolicyTab = () => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { mutateAsync: deleteApprovalPolicy } = useDeleteApprovalPolicy();
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"policy",
|
||||
"deletePolicy"
|
||||
] as const);
|
||||
|
||||
const handleDeletePolicy = async () => {
|
||||
const policyId = (popUp?.deletePolicy?.data as { policyId: string })?.policyId;
|
||||
if (!currentProject?.id) return;
|
||||
if (!policyId) return;
|
||||
|
||||
await deleteApprovalPolicy({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
policyId
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully deleted policy",
|
||||
type: "success"
|
||||
});
|
||||
handlePopUpClose("deletePolicy");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Approval Policies</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("policy")}
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
</div>
|
||||
<PoliciesTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<PolicyModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePolicy.isOpen}
|
||||
deleteKey="delete"
|
||||
title="Are you sure you want to delete this policy?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||
onDeleteApproved={handleDeletePolicy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faEllipsisV,
|
||||
faPencil,
|
||||
faTrash,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { User, Users } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useProject } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import {
|
||||
approvalPolicyQuery,
|
||||
ApprovalPolicyType,
|
||||
ApproverType
|
||||
} from "@app/hooks/api/approvalPolicies";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["policy", "deletePolicy"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const PoliciesTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
const projectId = currentProject?.id || "";
|
||||
|
||||
const { data: policies = [], isPending: isPoliciesLoading } = useQuery(
|
||||
approvalPolicyQuery.list({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
projectId
|
||||
})
|
||||
);
|
||||
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const getApproverLabel = (approverId: string, approverType: ApproverType) => {
|
||||
if (approverType === ApproverType.User) {
|
||||
const member = members?.find((m) => m.user.id === approverId);
|
||||
if (member) {
|
||||
return getMemberLabel(member);
|
||||
}
|
||||
} else if (approverType === ApproverType.Group) {
|
||||
const group = groups?.find(({ group: g }) => g.id === approverId);
|
||||
if (group) {
|
||||
return group.group.name;
|
||||
}
|
||||
}
|
||||
return approverId;
|
||||
};
|
||||
|
||||
const toggleRowExpansion = (policyId: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(policyId)) {
|
||||
newSet.delete(policyId);
|
||||
} else {
|
||||
newSet.add(policyId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-10" />
|
||||
<Th>Policy Name</Th>
|
||||
<Th>Max Approval Request TTL</Th>
|
||||
<Th>Min Access Duration</Th>
|
||||
<Th>Max Access Duration</Th>
|
||||
<Th>Conditions</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && <TableSkeleton columns={5} innerKey="approval-policies" />}
|
||||
{!isPoliciesLoading && policies.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={7}>
|
||||
<EmptyState title="No policies found" icon={faUsers} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isPoliciesLoading &&
|
||||
policies.map((policy) => {
|
||||
const isExpanded = expandedRows.has(policy.id);
|
||||
const maxTtl = policy.maxRequestTtl ? policy.maxRequestTtl : "No limit";
|
||||
const conditionsCount = policy.conditions.conditions.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
key={policy.id}
|
||||
className="group cursor-pointer hover:bg-mineshaft-700"
|
||||
onClick={() => toggleRowExpansion(policy.id)}
|
||||
>
|
||||
<Td>
|
||||
<IconButton
|
||||
ariaLabel="expand"
|
||||
variant="plain"
|
||||
className="p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleRowExpansion(policy.id);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronDown : faChevronRight}
|
||||
className="text-mineshaft-400"
|
||||
/>
|
||||
</IconButton>
|
||||
</Td>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{maxTtl}</Td>
|
||||
<Td>{policy.constraints.constraints.accessDuration.min}</Td>
|
||||
<Td>{policy.constraints.constraints.accessDuration.max}</Td>
|
||||
<Td>
|
||||
{conditionsCount} condition{conditionsCount !== 1 ? "s" : ""}
|
||||
</Td>
|
||||
<Td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<IconButton
|
||||
ariaLabel="More options"
|
||||
variant="plain"
|
||||
className="w-4 p-0"
|
||||
size="md"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("policy", {
|
||||
policyId: policy.id,
|
||||
policy
|
||||
});
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faPencil} />}
|
||||
>
|
||||
Edit Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deletePolicy", {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name
|
||||
});
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Policy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isExpanded && (
|
||||
<Tr className="bg-mineshaft-800">
|
||||
<Td colSpan={7} className="p-0">
|
||||
<div className="flex max-h-80 w-full gap-2 gap-4 overflow-auto overflow-x-hidden p-4">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 text-sm font-medium text-mineshaft-300">
|
||||
Approval Conditions
|
||||
</div>
|
||||
{policy.conditions.conditions.map((step, index) => (
|
||||
<Fragment key={`${policy.id}--${index + 1}`}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded border border-mineshaft-600 bg-mineshaft-900 p-3"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Account Paths:
|
||||
</span>
|
||||
<p className="text-sm text-mineshaft-100">
|
||||
{step.accountPaths.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{index < policy.conditions.conditions.length - 1 && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
<span className="px-2 text-xs font-medium text-mineshaft-400">
|
||||
OR
|
||||
</span>
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-2">
|
||||
<div className="mb-2 text-sm font-medium text-mineshaft-300">
|
||||
Approval Sequence
|
||||
</div>
|
||||
{policy.steps.map((step, index) => (
|
||||
<div
|
||||
key={`${policy.id}-step-${index + 1}`}
|
||||
className={twMerge(
|
||||
"mb-3 rounded border border-mineshaft-600 bg-mineshaft-900",
|
||||
index === policy.steps.length - 1 && "mb-0"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between bg-mineshaft-700 p-3">
|
||||
<div className="text-sm font-medium text-mineshaft-200">
|
||||
Step {index + 1}
|
||||
{step.name && (
|
||||
<span className="ml-2 text-mineshaft-400">
|
||||
({step.name})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-mineshaft-400">
|
||||
Requires {step.requiredApprovals} approval
|
||||
{step.requiredApprovals !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-3">
|
||||
{step.approvers.map((approver, approverIndex) => (
|
||||
<Badge
|
||||
variant="neutral"
|
||||
key={`${policy.id}-step-${index + 1}-approver-${approverIndex + 1}`}
|
||||
>
|
||||
{approver.type === ApproverType.Group ? (
|
||||
<User />
|
||||
) : (
|
||||
<Users />
|
||||
)}
|
||||
{getApproverLabel(approver.id, approver.type)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import {
|
||||
ApprovalPolicyType,
|
||||
TApprovalPolicy,
|
||||
useCreateApprovalPolicy,
|
||||
useUpdateApprovalPolicy
|
||||
} from "@app/hooks/api/approvalPolicies";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { PolicyApprovalSteps } from "./PolicySteps/PolicyApprovalSteps";
|
||||
import { PolicyConstraintsStep } from "./PolicySteps/PolicyConstraintsStep";
|
||||
import { PolicyDetailsStep } from "./PolicySteps/PolicyDetailsStep";
|
||||
import { PolicyReviewStep } from "./PolicySteps/PolicyReviewStep";
|
||||
import { PolicyFormSchema, TPolicyForm } from "./PolicySchema";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["policy"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["policy"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const FORM_STEPS: { name: string; key: string; fields: (keyof TPolicyForm)[] }[] = [
|
||||
{ name: "Details", key: "details", fields: ["name", "maxRequestTtl", "constraints"] },
|
||||
{ name: "Conditions", key: "conditions", fields: ["conditions"] },
|
||||
{ name: "Approvals", key: "approvals", fields: ["steps"] },
|
||||
{ name: "Review", key: "review", fields: [] }
|
||||
];
|
||||
|
||||
export const PolicyModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const isOpen = popUp?.policy?.isOpen;
|
||||
const policyData = popUp?.policy?.data as
|
||||
| { policyId: string; policy: TApprovalPolicy }
|
||||
| undefined;
|
||||
|
||||
const [selectedStepIndex, setSelectedStepIndex] = useState(0);
|
||||
|
||||
const formMethods = useForm<TPolicyForm>({
|
||||
resolver: zodResolver(PolicyFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
maxRequestTtl: null,
|
||||
conditions: [{ accountPaths: [] }],
|
||||
constraints: {
|
||||
accessDuration: {
|
||||
min: "30s",
|
||||
max: "7d"
|
||||
}
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
name: "",
|
||||
requiredApprovals: 1,
|
||||
notifyApprovers: true,
|
||||
approvers: []
|
||||
}
|
||||
]
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
const { handleSubmit, trigger, reset } = formMethods;
|
||||
|
||||
const { mutateAsync: createPolicy, isPending: isCreating } = useCreateApprovalPolicy();
|
||||
const { mutateAsync: updatePolicy, isPending: isUpdating } = useUpdateApprovalPolicy();
|
||||
|
||||
useEffect(() => {
|
||||
if (policyData?.policy) {
|
||||
reset({
|
||||
name: policyData.policy.name,
|
||||
maxRequestTtl: policyData.policy.maxRequestTtl,
|
||||
conditions: policyData.policy.conditions.conditions,
|
||||
constraints: policyData.policy.constraints.constraints,
|
||||
steps: policyData.policy.steps.map((step) => ({
|
||||
...step,
|
||||
name: step.name || ""
|
||||
}))
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
maxRequestTtl: null,
|
||||
conditions: [{ accountPaths: [] }],
|
||||
constraints: {
|
||||
accessDuration: {
|
||||
min: "30s",
|
||||
max: "7d"
|
||||
}
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
name: "",
|
||||
requiredApprovals: 1,
|
||||
notifyApprovers: true,
|
||||
approvers: []
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
setSelectedStepIndex(0);
|
||||
}, [policyData, reset, isOpen]);
|
||||
|
||||
const onSubmit = async (data: TPolicyForm) => {
|
||||
if (!currentProject?.id) return;
|
||||
|
||||
try {
|
||||
if (policyData?.policyId) {
|
||||
await updatePolicy({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
policyId: policyData.policyId,
|
||||
...data
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully updated policy",
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
await createPolicy({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
projectId: currentProject.id,
|
||||
...data
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully created policy",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("policy", false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: `Failed to ${policyData?.policyId ? "update" : "create"} policy`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isStepValid = async (index: number) => {
|
||||
const { fields } = FORM_STEPS[index];
|
||||
if (fields.length === 0) return true;
|
||||
return trigger(fields);
|
||||
};
|
||||
|
||||
const isFinalStep = selectedStepIndex === FORM_STEPS.length - 1;
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isFinalStep) {
|
||||
await handleSubmit(onSubmit)();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await isStepValid(selectedStepIndex);
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
setSelectedStepIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (selectedStepIndex === 0) {
|
||||
handlePopUpToggle("policy", false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedStepIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const isTabEnabled = async (index: number) => {
|
||||
let isEnabled = true;
|
||||
for (let i = index - 1; i >= 0; i -= 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
isEnabled = isEnabled && (await isStepValid(i));
|
||||
}
|
||||
|
||||
return isEnabled;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => handlePopUpToggle("policy", open)}>
|
||||
<ModalContent
|
||||
title={policyData?.policyId ? "Edit Policy" : "Create Policy"}
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<FormProvider {...formMethods}>
|
||||
<form>
|
||||
<Tab.Group selectedIndex={selectedStepIndex} onChange={setSelectedStepIndex}>
|
||||
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
{FORM_STEPS.map((step, index) => (
|
||||
<Tab
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
const isEnabled = await isTabEnabled(index);
|
||||
setSelectedStepIndex((prev) => (isEnabled ? index : prev));
|
||||
}}
|
||||
className={({ selected }) =>
|
||||
`-mb-[0.14rem] whitespace-nowrap ${index > selectedStepIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||
: "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
key={step.key}
|
||||
>
|
||||
{index + 1}. {step.name}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<PolicyDetailsStep />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PolicyConstraintsStep />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PolicyApprovalSteps />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PolicyReviewStep />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
<div className="mt-6 flex justify-between border-t border-mineshaft-600 pt-4">
|
||||
<Button type="button" variant="outline_bg" onClick={handlePrev}>
|
||||
{selectedStepIndex === 0 ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
isLoading={isCreating || isUpdating}
|
||||
isDisabled={isCreating || isUpdating}
|
||||
>
|
||||
{policyData?.policyId ? "Update" : "Create"} {isFinalStep ? "" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||
|
||||
// 30 to 7 days
|
||||
const DurationSchema = (
|
||||
min = 30,
|
||||
max = 604800,
|
||||
msg = "Duration must be between 30 seconds and 7 days"
|
||||
) =>
|
||||
z.string().refine(
|
||||
(val) => {
|
||||
const duration = ms(val) / 1000;
|
||||
|
||||
// 30 seconds to 7 days
|
||||
return duration >= min && duration <= max;
|
||||
},
|
||||
{ message: msg }
|
||||
);
|
||||
|
||||
export const PolicyFormSchema = z.object({
|
||||
name: z.string().min(1, "Policy name is required").max(128),
|
||||
maxRequestTtl: DurationSchema(
|
||||
3600,
|
||||
2592000,
|
||||
"Duration must be between 1 hour and 30 days"
|
||||
).nullish(),
|
||||
conditions: z
|
||||
.object({
|
||||
accountPaths: z.array(z.string().min(1))
|
||||
})
|
||||
.array()
|
||||
.min(1, "At least one condition is required"),
|
||||
constraints: z.object({
|
||||
accessDuration: z.object({
|
||||
min: DurationSchema(),
|
||||
max: DurationSchema()
|
||||
})
|
||||
}),
|
||||
steps: z
|
||||
.object({
|
||||
name: z.string().max(128).nullable().optional(),
|
||||
requiredApprovals: z.number().min(1).max(100),
|
||||
notifyApprovers: z.boolean().optional(),
|
||||
approvers: z
|
||||
.object({
|
||||
type: z.nativeEnum(ApproverType),
|
||||
id: z.string().uuid()
|
||||
})
|
||||
.array()
|
||||
.min(1, "At least one approver is required")
|
||||
})
|
||||
.array()
|
||||
.min(1, "At least one approval step is required")
|
||||
});
|
||||
|
||||
export type TPolicyForm = z.infer<typeof PolicyFormSchema>;
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { MultiValue } from "react-select";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, FilterableSelect, FormControl, IconButton, Input } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import { ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||
|
||||
import { TPolicyForm } from "../PolicySchema";
|
||||
|
||||
export const PolicyApprovalSteps = () => {
|
||||
const { control } = useFormContext<TPolicyForm>();
|
||||
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.id || "";
|
||||
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const {
|
||||
fields: stepFields,
|
||||
append: appendStep,
|
||||
remove: removeStep
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "steps"
|
||||
});
|
||||
|
||||
const memberOptions = useMemo(
|
||||
() =>
|
||||
members.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: ApproverType.User,
|
||||
isOrgMembershipActive: member.user.isOrgMembershipActive
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
groups?.map(({ group }) => ({
|
||||
id: group.id,
|
||||
type: ApproverType.Group
|
||||
})),
|
||||
[groups]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-mineshaft-200">Approval Steps</span>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
Define the approval workflow with sequential steps
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
appendStep({
|
||||
name: "",
|
||||
requiredApprovals: 1,
|
||||
notifyApprovers: true,
|
||||
approvers: []
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stepFields.map((field, index) => (
|
||||
<div key={field.id} className="rounded border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-xs font-semibold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Approval Step {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
{stepFields.length > 1 && (
|
||||
<IconButton
|
||||
ariaLabel="Remove step"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
onClick={() => removeStep(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-500" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`steps.${index}.name`}
|
||||
render={({ field: nameField }) => (
|
||||
<FormControl
|
||||
label="Step Name (Optional)"
|
||||
helperText="A descriptive name for this approval step"
|
||||
>
|
||||
<Input
|
||||
{...nameField}
|
||||
value={nameField.value || ""}
|
||||
placeholder="e.g., Security Team Review"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`steps.${index}.requiredApprovals`}
|
||||
render={({ field: approvalsField, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Required Approvals"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Number of approvers that must approve (1-100)"
|
||||
>
|
||||
<Input
|
||||
{...approvalsField}
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
onChange={(e) => approvalsField.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-mineshaft-200">Approvers</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`steps.${index}.approvers`}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
const userApprovers = value.filter((a) => a.type === ApproverType.User);
|
||||
const groupApprovers = value.filter((a) => a.type === ApproverType.Group);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={
|
||||
error?.message &&
|
||||
userApprovers.length === 0 &&
|
||||
groupApprovers.length === 0
|
||||
? error?.message
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
placeholder="Select users..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
const member = members?.find((m) => m.user.id === option.id);
|
||||
if (!member) return option.id;
|
||||
return getMemberLabel(member);
|
||||
}}
|
||||
value={userApprovers}
|
||||
onChange={(selected) => {
|
||||
const newApprovers = [
|
||||
...((selected as MultiValue<{
|
||||
type: ApproverType;
|
||||
id: string;
|
||||
}>) || []),
|
||||
...groupApprovers
|
||||
];
|
||||
onChange(newApprovers);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={
|
||||
error?.message &&
|
||||
userApprovers.length === 0 &&
|
||||
groupApprovers.length === 0
|
||||
? error?.message
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ??
|
||||
option.id
|
||||
}
|
||||
value={groupApprovers}
|
||||
onChange={(selected) => {
|
||||
const newApprovers = [
|
||||
...userApprovers,
|
||||
...((selected as MultiValue<{
|
||||
type: ApproverType;
|
||||
id: string;
|
||||
}>) || [])
|
||||
];
|
||||
onChange(newApprovers);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stepFields.length === 0 && (
|
||||
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-800/50 p-8 text-center">
|
||||
<p className="text-sm text-mineshaft-400">No approval steps defined</p>
|
||||
<p className="mt-1 text-xs text-mineshaft-500">
|
||||
Click "Add Step" to create your first approval step
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
|
||||
|
||||
import { TPolicyForm } from "../PolicySchema";
|
||||
|
||||
export const PolicyConstraintsStep = () => {
|
||||
const { control } = useFormContext<TPolicyForm>();
|
||||
|
||||
const {
|
||||
fields: conditionFields,
|
||||
append: appendCondition,
|
||||
remove: removeCondition
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "conditions"
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="pb-0.5 text-sm font-medium text-mineshaft-200">Conditions</p>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
Define which resources and account paths this policy applies to
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => appendCondition({ accountPaths: [] })}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{conditionFields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<div className="rounded border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Condition {index + 1}
|
||||
</span>
|
||||
{conditionFields.length > 1 && (
|
||||
<IconButton
|
||||
ariaLabel="Remove condition"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
onClick={() => removeCondition(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-500" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`conditions.${index}.accountPaths`}
|
||||
render={({ field: pathField, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Account Paths"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Matches any of the comma-separated account paths this condition applies to"
|
||||
>
|
||||
<Input
|
||||
value={pathField.value.join(", ")}
|
||||
onChange={(e) => {
|
||||
const paths = e.target.value
|
||||
.split(",")
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean);
|
||||
pathField.onChange(paths);
|
||||
}}
|
||||
placeholder="e.g., /admin/**, /users/john, /**"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{index < conditionFields.length - 1 && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
<span className="px-2 text-xs font-medium text-mineshaft-400">OR</span>
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { FormControl, Input } from "@app/components/v2";
|
||||
|
||||
import { TPolicyForm } from "../PolicySchema";
|
||||
|
||||
export const PolicyDetailsStep = () => {
|
||||
const { control } = useFormContext<TPolicyForm>();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Enter policy name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxRequestTtl"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label={<TtlFormLabel label="Max Approval Request TTL" />}
|
||||
helperText="Maximum time-to-live for requests. Must be between 1 hour and 30 days. Leave empty for no limit."
|
||||
>
|
||||
<Input {...field} value={field.value ?? ""} placeholder="1h" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-3">
|
||||
<p className="pb-0.5 text-sm font-medium text-mineshaft-200">
|
||||
PAM Account Access Duration TTL
|
||||
</p>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
Set minimum and maximum duration (in seconds) for pam account access
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="constraints.accessDuration.min"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Minimum TTL" />}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Must be between 30s and 7 days"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="constraints.accessDuration.max"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Maximum TTL" />}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Must be between 30s and 7 days"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useProject } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import { ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||
|
||||
import { TPolicyForm } from "../PolicySchema";
|
||||
|
||||
const ReviewField = ({ label, value }: { label: string; value: string | number }) => (
|
||||
<div className="flex gap-x-8">
|
||||
<span className="min-w-[140px] text-sm text-mineshaft-400">{label}</span>
|
||||
<span className="text-sm text-mineshaft-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PolicyReviewStep = () => {
|
||||
const { watch } = useFormContext<TPolicyForm>();
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.id || "";
|
||||
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const { name, maxRequestTtl, conditions, constraints, steps } = watch();
|
||||
|
||||
const getApproverLabel = (approverId: string, approverType: ApproverType) => {
|
||||
if (approverType === ApproverType.User) {
|
||||
const member = members?.find((m) => m.user.id === approverId);
|
||||
if (member) {
|
||||
return getMemberLabel(member);
|
||||
}
|
||||
} else if (approverType === ApproverType.Group) {
|
||||
const group = groups?.find(({ group: g }) => g.id === approverId);
|
||||
if (group) {
|
||||
return group.group.name;
|
||||
}
|
||||
}
|
||||
return approverId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">Policy Details</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ReviewField label="Policy Name" value={name || "Not set"} />
|
||||
<ReviewField label="Max Approval Request TTL" value={maxRequestTtl || "No Limit"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">
|
||||
PAM Account Access Duration TTL
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ReviewField label="Minimum Duration" value={constraints.accessDuration.min} />
|
||||
<ReviewField label="Maximum Duration" value={constraints.accessDuration.max} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">
|
||||
Conditions ({conditions.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div
|
||||
key={`condition-${index + 1}`}
|
||||
className="rounded border border-mineshaft-600 bg-mineshaft-800/50 p-3"
|
||||
>
|
||||
<div className="mb-2 text-xs font-medium text-mineshaft-300">
|
||||
Condition {index + 1}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-xs text-mineshaft-400">Account Paths: </span>
|
||||
<span className="text-xs text-mineshaft-200">
|
||||
{condition.accountPaths.length > 0
|
||||
? condition.accountPaths.join(", ")
|
||||
: "None specified"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">
|
||||
Approval Workflow ({steps.length} step{steps.length !== 1 ? "s" : ""})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const userApprovers = step.approvers.filter((a) => a.type === ApproverType.User);
|
||||
const groupApprovers = step.approvers.filter((a) => a.type === ApproverType.Group);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`step-${index + 1}`}
|
||||
className="rounded border border-mineshaft-600 bg-mineshaft-800/50 p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-xs font-medium text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-mineshaft-200">
|
||||
Step {index + 1}
|
||||
{step.name && (
|
||||
<span className="ml-2 text-xs text-mineshaft-400">({step.name})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-mineshaft-400">
|
||||
Requires {step.requiredApprovals} approval
|
||||
{step.requiredApprovals !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{userApprovers.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-mineshaft-400">
|
||||
User Approvers ({userApprovers.length}):
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userApprovers.map((approver, approverIndex) => (
|
||||
<div
|
||||
key={`step-${index + 1}-user-${approverIndex + 1}`}
|
||||
className="flex items-center gap-1.5 rounded bg-mineshaft-700 px-2 py-1 text-xs text-mineshaft-300"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUsers} className="text-mineshaft-400" />
|
||||
<span className="text-mineshaft-200">
|
||||
{getApproverLabel(approver.id, ApproverType.User)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupApprovers.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-mineshaft-400">
|
||||
Group Approvers ({groupApprovers.length}):
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupApprovers.map((approver, approverIndex) => (
|
||||
<div
|
||||
key={`step-${index + 1}-group-${approverIndex + 1}`}
|
||||
className="flex items-center gap-1.5 rounded bg-mineshaft-700 px-2 py-1 text-xs text-mineshaft-300"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUsers} className="text-mineshaft-400" />
|
||||
<span className="text-mineshaft-200">
|
||||
{getApproverLabel(approver.id, ApproverType.Group)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.approvers.length === 0 && (
|
||||
<span className="text-xs text-mineshaft-500">No approvers defined</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Notice */}
|
||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-3">
|
||||
<p className="text-xs text-mineshaft-300">
|
||||
Please review all the details above. Click "Create" to save this policy or
|
||||
"Back" to make changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { PolicyApprovalSteps } from "./PolicyApprovalSteps";
|
||||
export { PolicyConstraintsStep } from "./PolicyConstraintsStep";
|
||||
export { PolicyDetailsStep } from "./PolicyDetailsStep";
|
||||
export { PolicyReviewStep } from "./PolicyReviewStep";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PoliciesTable } from "./PoliciesTable";
|
||||
export { PolicyModal } from "./PolicyModal";
|
||||
@@ -0,0 +1 @@
|
||||
export { PolicyTab } from "./PolicyTab";
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faEllipsisV,
|
||||
faFileCircleQuestion,
|
||||
faFilter,
|
||||
faInfo,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
TextArea,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useProject } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import {
|
||||
approvalGrantQuery,
|
||||
ApprovalGrantStatus,
|
||||
useRevokeApprovalGrant
|
||||
} from "@app/hooks/api/approvalGrants";
|
||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||
|
||||
const revokeGrantSchema = z.object({
|
||||
revocationReason: z.string().max(256).optional()
|
||||
});
|
||||
|
||||
type TRevokeGrantForm = z.infer<typeof revokeGrantSchema>;
|
||||
|
||||
const getStatusBadgeColor = (status: ApprovalGrantStatus) => {
|
||||
switch (status) {
|
||||
case ApprovalGrantStatus.Active:
|
||||
return "success";
|
||||
case ApprovalGrantStatus.Expired:
|
||||
return "neutral";
|
||||
case ApprovalGrantStatus.Revoked:
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
export const RequestGrantTab = () => {
|
||||
const { currentProject } = useProject();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState(ApprovalGrantStatus.Active);
|
||||
const [revokeModalOpen, setRevokeModalOpen] = useState(false);
|
||||
const [grantToRevoke, setGrantToRevoke] = useState<string | null>(null);
|
||||
const { data: members = [] } = useGetWorkspaceUsers(currentProject.id);
|
||||
|
||||
const projectId = currentProject?.id || "";
|
||||
|
||||
const { data: grants = [], isPending: isGrantsLoading } = useQuery(
|
||||
approvalGrantQuery.list({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
projectId
|
||||
})
|
||||
);
|
||||
|
||||
const { mutateAsync: revokeGrant, isPending: isRevoking } = useRevokeApprovalGrant();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors }
|
||||
} = useForm<TRevokeGrantForm>({
|
||||
resolver: zodResolver(revokeGrantSchema)
|
||||
});
|
||||
|
||||
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
||||
initPerPage: getUserTablePreference("pamApprovalGrants", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const filteredGrants = useMemo(() => {
|
||||
let filtered = grants;
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(grant) =>
|
||||
grant.attributes.accountPath.toLowerCase().includes(search.toLowerCase()) ||
|
||||
grant.id.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered.filter((grant) => grant.status === filter);
|
||||
}, [grants, search, filter]);
|
||||
|
||||
const paginatedGrants = useMemo(
|
||||
() => filteredGrants.slice(offset, offset + perPage),
|
||||
[filteredGrants, offset, perPage]
|
||||
);
|
||||
|
||||
const openRevokeModal = (grantId: string) => {
|
||||
setGrantToRevoke(grantId);
|
||||
setRevokeModalOpen(true);
|
||||
};
|
||||
|
||||
const closeRevokeModal = () => {
|
||||
setRevokeModalOpen(false);
|
||||
setGrantToRevoke(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const getGrantedUser = (grantedUserId: string) => {
|
||||
const member = members?.find((m) => m.user.id === grantedUserId);
|
||||
if (member) {
|
||||
return getMemberLabel(member);
|
||||
}
|
||||
return grantedUserId;
|
||||
};
|
||||
|
||||
const handleRevokeGrant = async (data: TRevokeGrantForm) => {
|
||||
if (!grantToRevoke) return;
|
||||
|
||||
try {
|
||||
await revokeGrant({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
grantId: grantToRevoke,
|
||||
revocationReason: data.revocationReason
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Grant revoked successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
closeRevokeModal();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to revoke grant",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isTableFiltered = filter !== ApprovalGrantStatus.Active;
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Access Grants</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Grants"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-9.5 w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalGrantStatus.Active);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalGrantStatus.Active && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Active Grants
|
||||
</DropdownMenuItem>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Inactive Grants
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Filter by Status
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalGrantStatus.Expired);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalGrantStatus.Expired && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Expired
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalGrantStatus.Revoked);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalGrantStatus.Revoked && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Revoked
|
||||
</DropdownMenuItem>
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search access grants..."
|
||||
/>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>User</Th>
|
||||
<Th>Account Path</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Granted</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isGrantsLoading && <TableSkeleton columns={7} innerKey="access-grants" />}
|
||||
{!isGrantsLoading &&
|
||||
paginatedGrants.map((grant) => {
|
||||
const isActive = grant.status === ApprovalGrantStatus.Active;
|
||||
|
||||
return (
|
||||
<Tr key={grant.id} className="group">
|
||||
<Td>
|
||||
{grant.granteeUserId ? getGrantedUser(grant.granteeUserId) : "Unknown"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div>{grant.attributes.accountPath}</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-200">
|
||||
{grant.attributes.accessDuration}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
variant={
|
||||
isActive
|
||||
? getStatusBadgeColor(ApprovalGrantStatus.Active)
|
||||
: getStatusBadgeColor(grant.status)
|
||||
}
|
||||
className="capitalize"
|
||||
>
|
||||
{isActive ? "Active" : grant.status}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
{formatDistance(new Date(grant.createdAt), new Date(), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
{grant.expiresAt ? (
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
{formatDistance(new Date(grant.expiresAt), new Date(), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-mineshaft-500">Never</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<IconButton
|
||||
ariaLabel="More options"
|
||||
variant="plain"
|
||||
className="w-4 p-0"
|
||||
size="md"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-1" sideOffset={5}>
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId"
|
||||
params={{
|
||||
orgId: currentProject.orgId,
|
||||
projectId: currentProject.id,
|
||||
approvalRequestId: grant.requestId || ""
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem icon={<FontAwesomeIcon icon={faInfo} />}>
|
||||
Request Details
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openRevokeModal(grant.id);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faXmark} />}
|
||||
>
|
||||
Revoke Grant
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGrants.length) && (
|
||||
<Pagination
|
||||
count={filteredGrants.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isGrantsLoading && !filteredGrants?.length && (
|
||||
<EmptyState
|
||||
title={grants.length ? "No access grants match search..." : "No access grants found"}
|
||||
icon={grants.length ? faSearch : faFileCircleQuestion}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
<Modal isOpen={revokeModalOpen} onOpenChange={(open) => !open && closeRevokeModal()}>
|
||||
<ModalContent
|
||||
title="Revoke Access Grant"
|
||||
subTitle="Are you sure you want to revoke this access grant? This action cannot be undone."
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleRevokeGrant)}>
|
||||
<FormControl
|
||||
label="Revocation Reason (Optional)"
|
||||
isError={Boolean(errors.revocationReason)}
|
||||
errorText={errors.revocationReason?.message}
|
||||
>
|
||||
<TextArea
|
||||
{...register("revocationReason")}
|
||||
placeholder="Provide a reason for revoking this grant..."
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-6 flex items-center space-x-4">
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
type="submit"
|
||||
isLoading={isRevoking}
|
||||
isDisabled={isRevoking}
|
||||
>
|
||||
Revoke Grant
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={closeRevokeModal} isDisabled={isRevoking}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { RequestGrantTab } from "./RequestGrantTab";
|
||||
31
frontend/src/pages/pam/ApprovalsPage/route.tsx
Normal file
31
frontend/src/pages/pam/ApprovalsPage/route.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ApprovalControlTabs } from "@app/types/project";
|
||||
|
||||
import { ApprovalsPage } from "./ApprovalsPage";
|
||||
|
||||
const ApprovalPagePageQuerySchema = z.object({
|
||||
selectedTab: z.nativeEnum(ApprovalControlTabs).catch(ApprovalControlTabs.Requests)
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals"
|
||||
)({
|
||||
component: ApprovalsPage,
|
||||
validateSearch: zodValidator(ApprovalPagePageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({})]
|
||||
},
|
||||
beforeLoad: ({ context }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Approvals"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -10,16 +10,30 @@ import { PamResourceType, TPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
account?: TPamAccount;
|
||||
accountPath?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
|
||||
export const PamAccessAccountModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
account,
|
||||
projectId,
|
||||
accountPath
|
||||
}: Props) => {
|
||||
const [duration, setDuration] = useState("4h");
|
||||
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : "";
|
||||
const siteURL = `${protocol}//${hostname}${portSuffix}`;
|
||||
|
||||
const [duration, setDuration] = useState("4h");
|
||||
let fullAccountPath = account?.name ?? "";
|
||||
if (accountPath) {
|
||||
const path = accountPath.replace(/^\/+|\/+$/g, "");
|
||||
fullAccountPath = `${path}/${account?.name ?? ""}`;
|
||||
}
|
||||
|
||||
const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]);
|
||||
|
||||
@@ -68,13 +82,13 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
|
||||
switch (account.resource.resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
case PamResourceType.MySQL:
|
||||
return `infisical pam db access-account ${account.id} --duration ${cliDuration} --domain ${siteURL}`;
|
||||
return `infisical pam db access-account ${fullAccountPath} --project-id ${projectId} --duration ${cliDuration} --domain ${siteURL}`;
|
||||
case PamResourceType.SSH:
|
||||
return `infisical pam ssh access-account ${account.id} --duration ${cliDuration} --domain ${siteURL}`;
|
||||
return `infisical pam ssh access-account ${fullAccountPath} --project-id ${projectId} --duration ${cliDuration} --domain ${siteURL}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}, [account, cliDuration]);
|
||||
}, [account, fullAccountPath, projectId, cliDuration, siteURL]);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose
|
||||
} from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { useProject } from "@app/context";
|
||||
import {
|
||||
PamResourceType,
|
||||
TAwsIamAccount,
|
||||
TAwsIamResource,
|
||||
useGetPamResourceById
|
||||
} from "@app/hooks/api/pam";
|
||||
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TAwsIamAccount;
|
||||
resourceId?: string;
|
||||
resourceType?: PamResourceType;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/;
|
||||
|
||||
const AwsIamCredentialsSchema = z.object({
|
||||
targetRoleArn: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Target Role ARN is required")
|
||||
.refine((val) => arnRoleRegex.test(val), {
|
||||
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
|
||||
}),
|
||||
// Max 1 hour (3600s) due to AWS role chaining limitation, min 15 min (900s)
|
||||
defaultSessionDuration: z.coerce
|
||||
.number()
|
||||
.min(900, "Minimum session duration is 900 seconds (15 minutes)")
|
||||
.max(3600, "Maximum session duration is 3600 seconds (1 hour)")
|
||||
.default(3600)
|
||||
});
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: AwsIamCredentialsSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const AwsIamAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
const { projectId } = useProject();
|
||||
|
||||
const resourceIdToFetch = account?.resourceId || resourceId;
|
||||
const resourceTypeToFetch = account?.resource?.resourceType || resourceType;
|
||||
const { data: resource } = useGetPamResourceById(resourceTypeToFetch, resourceIdToFetch, {
|
||||
enabled: !!resourceIdToFetch && !!resourceTypeToFetch
|
||||
});
|
||||
|
||||
const pamRoleArn =
|
||||
(resource?.resourceType === PamResourceType.AwsIam &&
|
||||
(resource as TAwsIamResource).connectionDetails?.roleArn) ||
|
||||
"arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PAM_ROLE_NAME>";
|
||||
|
||||
const targetRoleTrustPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "${pamRoleArn}"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "${projectId}"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: account ?? {
|
||||
name: "",
|
||||
description: "",
|
||||
credentials: {
|
||||
targetRoleArn: "",
|
||||
defaultSessionDuration: 3600
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<GenericAccountFields />
|
||||
|
||||
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">AWS IAM Configuration</h4>
|
||||
|
||||
<Controller
|
||||
name="credentials.targetRoleArn"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-3"
|
||||
helperText="The ARN of the IAM role that users will assume to access the AWS Console"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Target Role ARN"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="arn:aws:iam::123456789012:role/infisical-pam-MyTargetRole"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="credentials.defaultSessionDuration"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
helperText="In seconds. Min 900 (15m), max 3600 (1h) due to AWS role chaining limit."
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Default Session Duration (seconds)"
|
||||
>
|
||||
<Input {...field} type="number" placeholder="3600" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="mb-4 w-full rounded-r border-l-2 border-l-primary bg-mineshaft-300/5"
|
||||
>
|
||||
<AccordionItem value="target-role-setup" className="border-b-0">
|
||||
<AccordionTrigger className="px-4 py-2.5 hover:no-underline [&[data-state=open]]:pb-1">
|
||||
<div className="flex items-center text-sm transition-colors duration-150 hover:text-primary">
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
Target Role Setup
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-2.5">
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
The target role must have a trust policy that allows the PAM role (created in the
|
||||
"Resources" tab) to assume it. If your target role name follows the
|
||||
wildcard pattern you defined in the PAM role's permissions policy, no
|
||||
additional changes are needed.
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Target role trust policy:
|
||||
</p>
|
||||
<div className="relative mb-3">
|
||||
<div className="absolute top-1 right-3">
|
||||
<CopyButton value={targetRoleTrustPolicy} size="sm" variant="plain" />
|
||||
</div>
|
||||
<pre className="max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{targetRoleTrustPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
<strong>Note:</strong> The Principal role ARN shown above is from the PAM Resource
|
||||
selected for this account. The External ID{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">{projectId}</code> is your
|
||||
current project ID. If your target role name doesn't match the wildcard pattern
|
||||
in your PAM Resource's role's permissions policy, you'll need to
|
||||
update that policy to include this role's ARN.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Account" : "Create Account"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { PamAccountHeader } from "../PamAccountHeader";
|
||||
import { AwsIamAccountForm } from "./AwsIamAccountForm";
|
||||
import { MySQLAccountForm } from "./MySQLAccountForm";
|
||||
import { PostgresAccountForm } from "./PostgresAccountForm";
|
||||
import { SshAccountForm } from "./SshAccountForm";
|
||||
@@ -70,6 +71,14 @@ const CreateForm = ({
|
||||
return (
|
||||
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
case PamResourceType.AwsIam:
|
||||
return (
|
||||
<AwsIamAccountForm
|
||||
onSubmit={onSubmit}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -100,6 +109,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
case PamResourceType.AwsIam:
|
||||
return <AwsIamAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
search: string;
|
||||
isFlatView: boolean;
|
||||
accountPath?: string;
|
||||
isAccessLoading?: boolean;
|
||||
};
|
||||
|
||||
export const PamAccountRow = ({
|
||||
@@ -50,7 +51,8 @@ export const PamAccountRow = ({
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isFlatView,
|
||||
accountPath
|
||||
accountPath,
|
||||
isAccessLoading
|
||||
}: Props) => {
|
||||
const { id, name } = account;
|
||||
|
||||
@@ -101,7 +103,7 @@ export const PamAccountRow = ({
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{account.lastRotatedAt && (
|
||||
{"lastRotatedAt" in account && account.lastRotatedAt && (
|
||||
<Tooltip
|
||||
className="max-w-sm text-center"
|
||||
isDisabled={!account.lastRotationMessage}
|
||||
@@ -127,6 +129,8 @@ export const PamAccountRow = ({
|
||||
leftIcon={<FontAwesomeIcon icon={faRightToBracket} />}
|
||||
onClick={() => onAccess(account)}
|
||||
size="xs"
|
||||
isLoading={isAccessLoading}
|
||||
isDisabled={isAccessLoading}
|
||||
>
|
||||
Access
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
faFilter,
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faPen,
|
||||
faPlus,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
PAM_RESOURCE_TYPE_MAP,
|
||||
PamAccountOrderBy,
|
||||
PamAccountView,
|
||||
PamResourceType,
|
||||
TPamAccount,
|
||||
TPamFolder
|
||||
} from "@app/hooks/api/pam";
|
||||
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
|
||||
@@ -65,8 +68,10 @@ import { PamAddFolderModal } from "./PamAddFolderModal";
|
||||
import { PamDeleteAccountModal } from "./PamDeleteAccountModal";
|
||||
import { PamDeleteFolderModal } from "./PamDeleteFolderModal";
|
||||
import { PamFolderRow } from "./PamFolderRow";
|
||||
import { PamRequestAccountAccessModal } from "./PamRequestAccountAccessModal";
|
||||
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
|
||||
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
|
||||
import { useAccessAwsIamAccount } from "./useAccessAwsIamAccount";
|
||||
|
||||
type PamAccountFilter = {
|
||||
resourceIds: string[];
|
||||
@@ -78,6 +83,7 @@ type Props = {
|
||||
|
||||
export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
|
||||
const { accessAwsIam, loadingAccountId } = useAccessAwsIamAccount();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"misc",
|
||||
@@ -86,6 +92,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
"deleteFolder",
|
||||
"addAccount",
|
||||
"accessAccount",
|
||||
"requestAccount",
|
||||
"updateAccount",
|
||||
"deleteAccount"
|
||||
] as const);
|
||||
@@ -319,6 +326,14 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPen} />}
|
||||
onClick={() => handlePopUpOpen("requestAccount")}
|
||||
className="h-10 transition-colors"
|
||||
>
|
||||
Request Account
|
||||
</Button>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPamAccountActions.Create}
|
||||
a={ProjectPermissionSub.PamAccounts}
|
||||
@@ -419,8 +434,21 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
search={search}
|
||||
isFlatView={accountView === PamAccountView.Flat}
|
||||
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
|
||||
onAccess={(e) => {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
isAccessLoading={loadingAccountId === account.id}
|
||||
onAccess={(e: TPamAccount) => {
|
||||
// For AWS IAM, directly open console without modal
|
||||
if (e.resource.resourceType === PamResourceType.AwsIam) {
|
||||
let fullAccountPath = e?.name;
|
||||
const folderPath = e.folderId ? folderPaths[e.folderId] : undefined;
|
||||
if (folderPath) {
|
||||
const path = folderPath.replace(/^\/+|\/+$/g, "");
|
||||
fullAccountPath = `${path}/${e?.name}`;
|
||||
}
|
||||
|
||||
accessAwsIam(e, fullAccountPath);
|
||||
} else {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
}
|
||||
}}
|
||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||
@@ -466,6 +494,16 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
isOpen={popUp.accessAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("accessAccount", isOpen)}
|
||||
account={popUp.accessAccount.data}
|
||||
accountPath={
|
||||
popUp.accessAccount.data?.folderId
|
||||
? folderPaths[popUp.accessAccount.data.folderId]
|
||||
: undefined
|
||||
}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<PamRequestAccountAccessModal
|
||||
isOpen={popUp.requestAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)}
|
||||
/>
|
||||
<PamDeleteAccountModal
|
||||
isOpen={popUp.deleteAccount.isOpen}
|
||||
|
||||
@@ -14,36 +14,6 @@ type Props = {
|
||||
currentFolderId: string | null;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onComplete: (account: TPamAccount) => void;
|
||||
projectId: string;
|
||||
currentFolderId: string | null;
|
||||
};
|
||||
|
||||
const Content = ({ onComplete, projectId, currentFolderId }: ContentProps) => {
|
||||
const [selectedResource, setSelectedResource] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
resourceType: PamResourceType;
|
||||
} | null>(null);
|
||||
|
||||
if (selectedResource) {
|
||||
return (
|
||||
<PamAccountForm
|
||||
onComplete={onComplete}
|
||||
onBack={() => setSelectedResource(null)}
|
||||
resourceId={selectedResource.id}
|
||||
resourceName={selectedResource.name}
|
||||
resourceType={selectedResource.resourceType}
|
||||
projectId={projectId}
|
||||
folderId={currentFolderId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />;
|
||||
};
|
||||
|
||||
export const PamAddAccountModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
@@ -51,22 +21,44 @@ export const PamAddAccountModal = ({
|
||||
onComplete,
|
||||
currentFolderId
|
||||
}: Props) => {
|
||||
const [selectedResource, setSelectedResource] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
resourceType: PamResourceType;
|
||||
} | null>(null);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
// Reset state when modal closes
|
||||
setSelectedResource(null);
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<Modal isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Add Account"
|
||||
subTitle="Select a resource to add an account under."
|
||||
bodyClassName="overflow-visible"
|
||||
bodyClassName={selectedResource ? undefined : "overflow-visible"}
|
||||
>
|
||||
<Content
|
||||
projectId={projectId}
|
||||
onComplete={(account) => {
|
||||
if (onComplete) onComplete(account);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
{selectedResource ? (
|
||||
<PamAccountForm
|
||||
onComplete={(account) => {
|
||||
if (onComplete) onComplete(account);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onBack={() => setSelectedResource(null)}
|
||||
resourceId={selectedResource.id}
|
||||
resourceName={selectedResource.name}
|
||||
resourceType={selectedResource.resourceType}
|
||||
projectId={projectId}
|
||||
folderId={currentFolderId ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||
import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations";
|
||||
import { TPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
account?: TPamAccount;
|
||||
accountPath?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
accountPath: z.string().min(1, "Account path is required"),
|
||||
accessDuration: z
|
||||
.string()
|
||||
.min(1, "Access duration is required")
|
||||
.refine(
|
||||
(value) => {
|
||||
try {
|
||||
const duration = ms(value);
|
||||
return duration > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Invalid duration format. Use formats like: 1h, 3d, 30m" }
|
||||
),
|
||||
justification: z.string().max(512).optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const Content = ({ onOpenChange, account, accountPath }: Props) => {
|
||||
const { projectId } = useProject();
|
||||
const { mutateAsync: createApprovalRequest, isPending: isSubmitting } =
|
||||
useCreateApprovalRequest();
|
||||
|
||||
const fullAccountPath = useMemo(() => {
|
||||
const accountName = account?.name ?? "";
|
||||
if (accountPath) {
|
||||
const path = accountPath.replace(/^\/+|\/+$/g, "");
|
||||
return `${path}/${accountName}`;
|
||||
}
|
||||
return accountName;
|
||||
}, [account, accountPath]);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
accountPath: fullAccountPath,
|
||||
accessDuration: "4h",
|
||||
justification: ""
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty }
|
||||
} = form;
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
try {
|
||||
await createApprovalRequest({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
projectId,
|
||||
justification: formData.justification || null,
|
||||
requestData: {
|
||||
accountPath: formData.accountPath,
|
||||
accessDuration: formData.accessDuration
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Access request submitted successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to submit access request",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="accountPath"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="Account path including the account name. Supports glob patterns (e.g., /folder/**, /*/account-name)"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Account Path"
|
||||
>
|
||||
<Input autoFocus placeholder="/folder/account-name" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="accessDuration"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Access Duration" />}
|
||||
helperText="Duration of access requested"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
>
|
||||
<Input placeholder="4h" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="justification"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="Provide a reason for requesting access"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Justification"
|
||||
isOptional
|
||||
>
|
||||
<TextArea placeholder="I need access to debug production issue..." {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
Request Access
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const PamRequestAccountAccessModal = (props: Props) => {
|
||||
const { isOpen, onOpenChange } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl pb-2"
|
||||
title="Request Account Access"
|
||||
subTitle="Request access to this account path"
|
||||
>
|
||||
<Content {...props} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -80,6 +80,8 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear search when a value is selected so the selected label is shown
|
||||
setSearch("");
|
||||
onChange(newValue);
|
||||
}}
|
||||
isLoading={isPending}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam";
|
||||
import { TAwsIamCredentials } from "@app/hooks/api/pam/types";
|
||||
|
||||
export const useAccessAwsIamAccount = () => {
|
||||
const accessPamAccount = useAccessPamAccount();
|
||||
const [loadingAccountId, setLoadingAccountId] = useState<string | null>(null);
|
||||
|
||||
const accessAwsIam = async (account: TPamAccount, accountPath: string) => {
|
||||
if (account.resource.resourceType !== PamResourceType.AwsIam) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setLoadingAccountId(account.id);
|
||||
|
||||
try {
|
||||
const response = await accessPamAccount.mutateAsync({
|
||||
accountId: account.id,
|
||||
accountPath,
|
||||
projectId: account.projectId,
|
||||
duration: `${(account.credentials as TAwsIamCredentials).defaultSessionDuration}s`
|
||||
});
|
||||
|
||||
if (response.consoleUrl) {
|
||||
// Open the AWS Console URL in a new tab
|
||||
window.open(response.consoleUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
createNotification({
|
||||
text: "AWS Console opened in new tab",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Failed to generate AWS Console URL",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setLoadingAccountId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
accessAwsIam,
|
||||
isPending: accessPamAccount.isPending,
|
||||
loadingAccountId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose
|
||||
} from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { useProject } from "@app/context";
|
||||
import { PamResourceType, TAwsIamResource } from "@app/hooks/api/pam";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
resource?: TAwsIamResource;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/;
|
||||
|
||||
const AwsIamConnectionDetailsSchema = z.object({
|
||||
roleArn: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "PAM Role ARN is required")
|
||||
.refine((val) => arnRoleRegex.test(val), {
|
||||
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 64, field: "Name" }),
|
||||
resourceType: z.literal(PamResourceType.AwsIam),
|
||||
connectionDetails: AwsIamConnectionDetailsSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
// Infisical AWS account IDs for trust policy
|
||||
const INFISICAL_AWS_ACCOUNT_US = "381492033652";
|
||||
const INFISICAL_AWS_ACCOUNT_EU = "345594589636";
|
||||
|
||||
export const AwsIamResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(resource);
|
||||
const { projectId } = useProject();
|
||||
|
||||
const permissionsPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PREFIX>-*"
|
||||
}]
|
||||
}`;
|
||||
|
||||
const trustPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::<INFISICAL_AWS_ACCOUNT_ID>:root"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "${projectId}"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.AwsIam,
|
||||
connectionDetails: {
|
||||
roleArn: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="Name must be slug-friendly"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Name"
|
||||
>
|
||||
<Input autoFocus placeholder="my-aws-console" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="connectionDetails.roleArn"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="The ARN of the Infisical PAM role that can assume target roles"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="PAM Role ARN"
|
||||
>
|
||||
<Input placeholder="arn:aws:iam::123456789012:role/InfisicalPAMRole" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="mt-4 w-full rounded-r border-l-2 border-l-primary bg-mineshaft-300/5"
|
||||
>
|
||||
<AccordionItem value="aws-iam-role-setup" className="border-b-0">
|
||||
<AccordionTrigger className="px-4 py-2.5 hover:no-underline [&[data-state=open]]:pb-1">
|
||||
<div className="flex items-center text-sm transition-colors duration-150 hover:text-primary">
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
AWS IAM Role Setup
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-2.5">
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
Before creating this resource, you need to set up an IAM role in your AWS account
|
||||
that Infisical can assume. Follow these steps:
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Step 1: Create a permissions policy for assuming target roles
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
This policy allows the PAM role to assume target roles. We recommend using a
|
||||
wildcard pattern (e.g.,{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">pam-*</code> or{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">privileged-*</code>) so you
|
||||
can add new accounts without updating this policy. Choose a prefix that fits your
|
||||
naming conventions.
|
||||
</p>
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute top-1 right-1">
|
||||
<CopyButton value={permissionsPolicy} size="sm" variant="plain" />
|
||||
</div>
|
||||
<pre className="max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{permissionsPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Step 2: Create the PAM role with a trust policy
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
Create an IAM role (e.g.,{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">InfisicalPAMRole</code>)
|
||||
with the permissions policy above and the following trust policy:
|
||||
</p>
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute top-1 right-3">
|
||||
<CopyButton value={trustPolicy} size="sm" variant="plain" />
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{trustPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
<strong>Note:</strong> Use{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">
|
||||
{INFISICAL_AWS_ACCOUNT_US}
|
||||
</code>{" "}
|
||||
for US region or{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">
|
||||
{INFISICAL_AWS_ACCOUNT_EU}
|
||||
</code>{" "}
|
||||
for EU region. Replace{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">
|
||||
<INFISICAL_AWS_ACCOUNT_ID>
|
||||
</code>{" "}
|
||||
with the appropriate Infisical AWS account ID for your region. The External ID{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">{projectId}</code> is your
|
||||
current project ID.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Details" : "Create Resource"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { PamResourceHeader } from "../PamResourceHeader";
|
||||
import { AwsIamResourceForm } from "./AwsIamResourceForm";
|
||||
import { MySQLResourceForm } from "./MySQLResourceForm";
|
||||
import { PostgresResourceForm } from "./PostgresResourceForm";
|
||||
import { SSHResourceForm } from "./SSHResourceForm";
|
||||
@@ -54,6 +55,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
|
||||
return <MySQLResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SSHResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.AwsIam:
|
||||
return <AwsIamResourceForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
|
||||
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.AwsIam:
|
||||
return <AwsIamResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
|
||||
|
||||
import { CommandLogView } from "./CommandLogView";
|
||||
@@ -13,6 +16,7 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
||||
const isDatabaseSession =
|
||||
session.resourceType === PamResourceType.Postgres ||
|
||||
session.resourceType === PamResourceType.MySQL;
|
||||
const isAwsIamSession = session.resourceType === PamResourceType.AwsIam;
|
||||
const hasLogs = session.logs.length > 0;
|
||||
|
||||
return (
|
||||
@@ -23,7 +27,27 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
||||
|
||||
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
|
||||
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
|
||||
{!hasLogs && (
|
||||
{isAwsIamSession && (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
<div className="text-center">
|
||||
<div className="mb-2">AWS Console session activity is logged in AWS CloudTrail</div>
|
||||
<div className="text-xs text-bunker-400">
|
||||
View detailed activity logs for this session in your AWS CloudTrail console.
|
||||
<br />
|
||||
<a
|
||||
href="https://console.aws.amazon.com/cloudtrail"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
Open AWS CloudTrail
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasLogs && !isAwsIamSession && (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
<div className="text-center">
|
||||
<div className="mb-2">Session logs are not yet available</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionAppConnectionActions,
|
||||
ProjectPermissionApprovalRequestActions,
|
||||
ProjectPermissionApprovalRequestGrantActions,
|
||||
ProjectPermissionAuditLogsActions,
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
@@ -258,6 +260,16 @@ const PamSessionPolicyActionSchema = z.object({
|
||||
[ProjectPermissionPamSessionActions.Read]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const ApprovalRequestPolicyActionSchema = z.object({
|
||||
[ProjectPermissionApprovalRequestActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionApprovalRequestActions.Create]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const ApprovalRequestGrantPolicyActionSchema = z.object({
|
||||
[ProjectPermissionApprovalRequestGrantActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionApprovalRequestGrantActions.Revoke]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretRollbackPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
create: z.boolean().optional()
|
||||
@@ -468,7 +480,12 @@ export const projectRoleFormSchema = z.object({
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.PamSessions]: PamSessionPolicyActionSchema.array().default([])
|
||||
[ProjectPermissionSub.PamSessions]: PamSessionPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.ApprovalRequests]: ApprovalRequestPolicyActionSchema.array().default(
|
||||
[]
|
||||
),
|
||||
[ProjectPermissionSub.ApprovalRequestGrants]:
|
||||
ApprovalRequestGrantPolicyActionSchema.array().default([])
|
||||
})
|
||||
.partial()
|
||||
.optional()
|
||||
@@ -1289,6 +1306,27 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
// Map actions to the keys defined in ApprovalPolicyActionSchema
|
||||
if (canRead) formVal[subject]![0][ProjectPermissionPamAccountActions.Read] = true;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.ApprovalRequests) {
|
||||
const canRead = action.includes(ProjectPermissionApprovalRequestActions.Read);
|
||||
const canCreate = action.includes(ProjectPermissionApprovalRequestActions.Create);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
if (canRead) formVal[subject]![0][ProjectPermissionApprovalRequestActions.Read] = true;
|
||||
if (canCreate) formVal[subject]![0][ProjectPermissionApprovalRequestActions.Create] = true;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.ApprovalRequestGrants) {
|
||||
const canRead = action.includes(ProjectPermissionApprovalRequestGrantActions.Read);
|
||||
const canRevoke = action.includes(ProjectPermissionApprovalRequestGrantActions.Revoke);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
if (canRead) formVal[subject]![0][ProjectPermissionApprovalRequestGrantActions.Read] = true;
|
||||
if (canRevoke)
|
||||
formVal[subject]![0][ProjectPermissionApprovalRequestGrantActions.Revoke] = true;
|
||||
}
|
||||
});
|
||||
|
||||
return formVal;
|
||||
@@ -1958,6 +1996,20 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.PamSessions]: {
|
||||
title: "Sessions",
|
||||
actions: [{ label: "Read", value: ProjectPermissionPamSessionActions.Read }]
|
||||
},
|
||||
[ProjectPermissionSub.ApprovalRequests]: {
|
||||
title: "Approval Requests",
|
||||
actions: [
|
||||
{ label: "Read", value: ProjectPermissionApprovalRequestActions.Read },
|
||||
{ label: "Create", value: ProjectPermissionApprovalRequestActions.Create }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.ApprovalRequestGrants]: {
|
||||
title: "Approval Request Grants",
|
||||
actions: [
|
||||
{ label: "Read", value: ProjectPermissionApprovalRequestGrantActions.Read },
|
||||
{ label: "Revoke", value: ProjectPermissionApprovalRequestGrantActions.Revoke }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1968,7 +2020,9 @@ const SharedPermissionSubjects = {
|
||||
[ProjectPermissionSub.Identity]: true,
|
||||
[ProjectPermissionSub.Project]: true,
|
||||
[ProjectPermissionSub.Role]: true,
|
||||
[ProjectPermissionSub.Settings]: true
|
||||
[ProjectPermissionSub.Settings]: true,
|
||||
[ProjectPermissionSub.ApprovalRequests]: true,
|
||||
[ProjectPermissionSub.ApprovalRequestGrants]: true
|
||||
};
|
||||
|
||||
const SecretsManagerPermissionSubjects = (enabled = false) => ({
|
||||
|
||||
@@ -291,7 +291,7 @@ export const ReviewAccessRequestModal = ({
|
||||
{request.user &&
|
||||
(request.user.firstName || request.user.lastName) &&
|
||||
request.user.email ? (
|
||||
<span className="inline font-bold">
|
||||
<span className="inline font-medium">
|
||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -111,6 +111,7 @@ import { Route as secretManagerSecretApprovalsPageRouteImport } from './pages/se
|
||||
import { Route as secretManagerIPAllowlistPageRouteImport } from './pages/secret-manager/IPAllowlistPage/route'
|
||||
import { Route as pamSettingsPageRouteImport } from './pages/pam/SettingsPage/route'
|
||||
import { Route as pamPamResourcesPageRouteImport } from './pages/pam/PamResourcesPage/route'
|
||||
import { Route as pamApprovalsPageRouteImport } from './pages/pam/ApprovalsPage/route'
|
||||
import { Route as pamPamAccountsPageRouteImport } from './pages/pam/PamAccountsPage/route'
|
||||
import { Route as kmsSettingsPageRouteImport } from './pages/kms/SettingsPage/route'
|
||||
import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/route'
|
||||
@@ -150,6 +151,7 @@ import { Route as secretManagerSecretDashboardPageRouteImport } from './pages/se
|
||||
import { Route as secretManagerIntegrationsSelectIntegrationAuthPageRouteImport } from './pages/secret-manager/integrations/SelectIntegrationAuthPage/route'
|
||||
import { Route as secretManagerIntegrationsDetailsByIDPageRouteImport } from './pages/secret-manager/IntegrationsDetailsByIDPage/route'
|
||||
import { Route as pamPamSessionsByIDPageRouteImport } from './pages/pam/PamSessionsByIDPage/route'
|
||||
import { Route as pamApprovalRequestDetailPageRouteImport } from './pages/pam/ApprovalRequestDetailPage/route'
|
||||
import { Route as certManagerPkiSubscriberDetailsByIDPageRouteImport } from './pages/cert-manager/PkiSubscriberDetailsByIDPage/route'
|
||||
import { Route as certManagerPkiSyncDetailsByIDPageRouteImport } from './pages/cert-manager/PkiSyncDetailsByIDPage/route'
|
||||
import { Route as certManagerCertAuthDetailsByIDPageRouteImport } from './pages/cert-manager/CertAuthDetailsByIDPage/route'
|
||||
@@ -1226,6 +1228,12 @@ const pamPamResourcesPageRouteRoute = pamPamResourcesPageRouteImport.update({
|
||||
getParentRoute: () => pamLayoutRoute,
|
||||
} as any)
|
||||
|
||||
const pamApprovalsPageRouteRoute = pamApprovalsPageRouteImport.update({
|
||||
id: '/approvals',
|
||||
path: '/approvals',
|
||||
getParentRoute: () => pamLayoutRoute,
|
||||
} as any)
|
||||
|
||||
const pamPamAccountsPageRouteRoute = pamPamAccountsPageRouteImport.update({
|
||||
id: '/accounts',
|
||||
path: '/accounts',
|
||||
@@ -1497,6 +1505,13 @@ const pamPamSessionsByIDPageRouteRoute =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectIdPamLayoutSessionsRoute,
|
||||
} as any)
|
||||
|
||||
const pamApprovalRequestDetailPageRouteRoute =
|
||||
pamApprovalRequestDetailPageRouteImport.update({
|
||||
id: '/approval-requests/$approvalRequestId',
|
||||
path: '/approval-requests/$approvalRequestId',
|
||||
getParentRoute: () => pamLayoutRoute,
|
||||
} as any)
|
||||
|
||||
const certManagerPkiSubscriberDetailsByIDPageRouteRoute =
|
||||
certManagerPkiSubscriberDetailsByIDPageRouteImport.update({
|
||||
id: '/$subscriberName',
|
||||
@@ -2902,6 +2917,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof pamPamAccountsPageRouteImport
|
||||
parentRoute: typeof pamLayoutImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals'
|
||||
path: '/approvals'
|
||||
fullPath: '/organizations/$orgId/projects/pam/$projectId/approvals'
|
||||
preLoaderRoute: typeof pamApprovalsPageRouteImport
|
||||
parentRoute: typeof pamLayoutImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources'
|
||||
path: '/resources'
|
||||
@@ -3203,6 +3225,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof certManagerPkiSubscriberDetailsByIDPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsCertManagementProjectIdCertManagerLayoutSubscribersImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId'
|
||||
path: '/approval-requests/$approvalRequestId'
|
||||
fullPath: '/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId'
|
||||
preLoaderRoute: typeof pamApprovalRequestDetailPageRouteImport
|
||||
parentRoute: typeof pamLayoutImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId'
|
||||
path: '/$sessionId'
|
||||
@@ -4214,11 +4243,13 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectI
|
||||
|
||||
interface pamLayoutRouteChildren {
|
||||
pamPamAccountsPageRouteRoute: typeof pamPamAccountsPageRouteRoute
|
||||
pamApprovalsPageRouteRoute: typeof pamApprovalsPageRouteRoute
|
||||
pamPamResourcesPageRouteRoute: typeof pamPamResourcesPageRouteRoute
|
||||
pamSettingsPageRouteRoute: typeof pamSettingsPageRouteRoute
|
||||
projectAccessControlPageRoutePamRoute: typeof projectAccessControlPageRoutePamRoute
|
||||
projectAuditLogsPageRoutePamRoute: typeof projectAuditLogsPageRoutePamRoute
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectIdPamLayoutSessionsRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectIdPamLayoutSessionsRouteWithChildren
|
||||
pamApprovalRequestDetailPageRouteRoute: typeof pamApprovalRequestDetailPageRouteRoute
|
||||
projectGroupDetailsByIDPageRoutePamRoute: typeof projectGroupDetailsByIDPageRoutePamRoute
|
||||
projectIdentityDetailsByIDPageRoutePamRoute: typeof projectIdentityDetailsByIDPageRoutePamRoute
|
||||
projectMemberDetailsByIDPageRoutePamRoute: typeof projectMemberDetailsByIDPageRoutePamRoute
|
||||
@@ -4227,12 +4258,15 @@ interface pamLayoutRouteChildren {
|
||||
|
||||
const pamLayoutRouteChildren: pamLayoutRouteChildren = {
|
||||
pamPamAccountsPageRouteRoute: pamPamAccountsPageRouteRoute,
|
||||
pamApprovalsPageRouteRoute: pamApprovalsPageRouteRoute,
|
||||
pamPamResourcesPageRouteRoute: pamPamResourcesPageRouteRoute,
|
||||
pamSettingsPageRouteRoute: pamSettingsPageRouteRoute,
|
||||
projectAccessControlPageRoutePamRoute: projectAccessControlPageRoutePamRoute,
|
||||
projectAuditLogsPageRoutePamRoute: projectAuditLogsPageRoutePamRoute,
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectIdPamLayoutSessionsRoute:
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectIdPamLayoutSessionsRouteWithChildren,
|
||||
pamApprovalRequestDetailPageRouteRoute:
|
||||
pamApprovalRequestDetailPageRouteRoute,
|
||||
projectGroupDetailsByIDPageRoutePamRoute:
|
||||
projectGroupDetailsByIDPageRoutePamRoute,
|
||||
projectIdentityDetailsByIDPageRoutePamRoute:
|
||||
@@ -5159,6 +5193,7 @@ export interface FileRoutesByFullPath {
|
||||
'/organizations/$orgId/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
|
||||
'/organizations/$orgId/projects/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/accounts': typeof pamPamAccountsPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/approvals': typeof pamApprovalsPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/resources': typeof pamPamResourcesPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/settings': typeof pamSettingsPageRouteRoute
|
||||
'/organizations/$orgId/projects/secret-management/$projectId/allowlist': typeof secretManagerIPAllowlistPageRouteRoute
|
||||
@@ -5202,6 +5237,7 @@ export interface FileRoutesByFullPath {
|
||||
'/organizations/$orgId/projects/cert-management/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId': typeof certManagerPkiSyncDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName': typeof certManagerPkiSubscriberDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId': typeof pamApprovalRequestDetailPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId': typeof pamPamSessionsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/secret-management/$projectId/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/secret-management/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
|
||||
@@ -5395,6 +5431,7 @@ export interface FileRoutesByTo {
|
||||
'/organizations/$orgId/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
|
||||
'/organizations/$orgId/projects/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/accounts': typeof pamPamAccountsPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/approvals': typeof pamApprovalsPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/resources': typeof pamPamResourcesPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/settings': typeof pamSettingsPageRouteRoute
|
||||
'/organizations/$orgId/projects/secret-management/$projectId/allowlist': typeof secretManagerIPAllowlistPageRouteRoute
|
||||
@@ -5432,6 +5469,7 @@ export interface FileRoutesByTo {
|
||||
'/organizations/$orgId/projects/cert-management/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId': typeof certManagerPkiSyncDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName': typeof certManagerPkiSubscriberDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId': typeof pamApprovalRequestDetailPageRouteRoute
|
||||
'/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId': typeof pamPamSessionsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/secret-management/$projectId/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute
|
||||
'/organizations/$orgId/projects/secret-management/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
|
||||
@@ -5642,6 +5680,7 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/settings': typeof kmsSettingsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/accounts': typeof pamPamAccountsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals': typeof pamApprovalsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources': typeof pamPamResourcesPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/settings': typeof pamSettingsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/allowlist': typeof secretManagerIPAllowlistPageRouteRoute
|
||||
@@ -5685,6 +5724,7 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId': typeof certManagerPkiSyncDetailsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/$subscriberName': typeof certManagerPkiSubscriberDetailsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId': typeof pamApprovalRequestDetailPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId': typeof pamPamSessionsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
|
||||
@@ -5887,6 +5927,7 @@ export interface FileRouteTypes {
|
||||
| '/organizations/$orgId/projects/kms/$projectId/overview'
|
||||
| '/organizations/$orgId/projects/kms/$projectId/settings'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/accounts'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/approvals'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/resources'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/settings'
|
||||
| '/organizations/$orgId/projects/secret-management/$projectId/allowlist'
|
||||
@@ -5930,6 +5971,7 @@ export interface FileRouteTypes {
|
||||
| '/organizations/$orgId/projects/cert-management/$projectId/ca/$caId'
|
||||
| '/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId'
|
||||
| '/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId'
|
||||
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/$integrationId'
|
||||
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/select-integration-auth'
|
||||
@@ -6122,6 +6164,7 @@ export interface FileRouteTypes {
|
||||
| '/organizations/$orgId/projects/kms/$projectId/overview'
|
||||
| '/organizations/$orgId/projects/kms/$projectId/settings'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/accounts'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/approvals'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/resources'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/settings'
|
||||
| '/organizations/$orgId/projects/secret-management/$projectId/allowlist'
|
||||
@@ -6159,6 +6202,7 @@ export interface FileRouteTypes {
|
||||
| '/organizations/$orgId/projects/cert-management/$projectId/ca/$caId'
|
||||
| '/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId'
|
||||
| '/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId'
|
||||
| '/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId'
|
||||
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/$integrationId'
|
||||
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/select-integration-auth'
|
||||
@@ -6367,6 +6411,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/overview'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/settings'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/accounts'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/settings'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/allowlist'
|
||||
@@ -6410,6 +6455,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/$subscriberName'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/$integrationId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/select-integration-auth'
|
||||
@@ -7058,11 +7104,13 @@ export const routeTree = rootRoute
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId",
|
||||
"children": [
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/accounts",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/settings",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/access-management",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/audit-logs",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/groups/$groupId",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/identities/$identityId",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/members/$membershipId",
|
||||
@@ -7156,6 +7204,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "pam/PamAccountsPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals": {
|
||||
"filePath": "pam/ApprovalsPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources": {
|
||||
"filePath": "pam/PamResourcesPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout"
|
||||
@@ -7427,6 +7479,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "cert-manager/PkiSubscriberDetailsByIDPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId": {
|
||||
"filePath": "pam/ApprovalRequestDetailPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId": {
|
||||
"filePath": "pam/PamSessionsByIDPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions"
|
||||
|
||||
@@ -358,6 +358,8 @@ const pamRoutes = route("/organizations/$orgId/projects/pam/$projectId", [
|
||||
route("/resources", "pam/PamResourcesPage/route.tsx"),
|
||||
route("/audit-logs", "project/AuditLogsPage/route-pam.tsx"),
|
||||
route("/settings", "pam/SettingsPage/route.tsx"),
|
||||
route("/approvals", "pam/ApprovalsPage/route.tsx"),
|
||||
route("/approval-requests/$approvalRequestId", "pam/ApprovalRequestDetailPage/route.tsx"),
|
||||
|
||||
// Access Management
|
||||
route("/access-management", "project/AccessControlPage/route-pam.tsx"),
|
||||
|
||||
@@ -5,3 +5,9 @@ export enum ProjectAccessControlTabs {
|
||||
Identities = "identities",
|
||||
ServiceTokens = "service-tokens"
|
||||
}
|
||||
|
||||
export enum ApprovalControlTabs {
|
||||
Requests = "requests",
|
||||
Policies = "policies",
|
||||
Grants = "grants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user