Merge branch 'main' into fix/UISecretEditPermissionButNotReadValuePermission

This commit is contained in:
carlosmonastyrski
2025-04-28 13:33:05 -03:00
348 changed files with 7982 additions and 856 deletions
@@ -1,8 +1,6 @@
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { TAuth0ClientSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/auth0-client-secret-rotation";
import { ViewRotationGeneratedCredentialsDisplay } from "./shared";
import { CredentialDisplay, ViewRotationGeneratedCredentialsDisplay } from "./shared";
type Props = {
generatedCredentialsResponse: TAuth0ClientSecretRotationGeneratedCredentialsResponse;
@@ -17,40 +15,23 @@ export const ViewAuth0ClientSecretRotationGeneratedCredentials = ({
const inactiveCredentials = generatedCredentials[inactiveIndex];
return (
<>
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Client ID">{activeCredentials?.clientId}</CredentialDisplay>
<CredentialDisplay isSensitive label="Client Secret">
{activeCredentials?.clientSecret}
</CredentialDisplay>
</>
}
inactiveCredentials={
<>
<CredentialDisplay label="Client ID">{inactiveCredentials?.clientId}</CredentialDisplay>
<CredentialDisplay isSensitive label="Client Secret">
{inactiveCredentials?.clientSecret}
</CredentialDisplay>
</>
}
/>
<NoticeBannerV2 title="Auth0 Retired Credentials Behavior">
<p className="text-sm text-mineshaft-300">
Due to how Auth0 client secrets are rotated, retired credentials will not be able to
authenticate with Auth0 during their{" "}
<a
target="_blank"
href="https://infisical.com/docs/documentation/platform/secret-rotation/overview#how-rotation-works"
rel="noopener noreferrer"
className="underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
>
inactive period
</a>
. This is a limitation of the Auth0 platform and cannot be rectified by Infisical.
</p>
</NoticeBannerV2>
</>
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Client ID">{activeCredentials?.clientId}</CredentialDisplay>
<CredentialDisplay isSensitive label="Client Secret">
{activeCredentials?.clientSecret}
</CredentialDisplay>
</>
}
inactiveCredentials={
<>
<CredentialDisplay label="Client ID">{inactiveCredentials?.clientId}</CredentialDisplay>
<CredentialDisplay isSensitive label="Client Secret">
{inactiveCredentials?.clientSecret}
</CredentialDisplay>
</>
}
/>
);
};
@@ -0,0 +1,42 @@
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
import { TAwsIamUserSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation";
import { ViewRotationGeneratedCredentialsDisplay } from "./shared";
type Props = {
generatedCredentialsResponse: TAwsIamUserSecretRotationGeneratedCredentialsResponse;
};
export const ViewAwsIamUserSecretRotationGeneratedCredentials = ({
generatedCredentialsResponse: { generatedCredentials, activeIndex }
}: Props) => {
const inactiveIndex = activeIndex === 0 ? 1 : 0;
const activeCredentials = generatedCredentials[activeIndex];
const inactiveCredentials = generatedCredentials[inactiveIndex];
return (
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Access Key ID">
{activeCredentials?.accessKeyId}
</CredentialDisplay>
<CredentialDisplay isSensitive label="Secret Access Key">
{activeCredentials?.secretAccessKey}
</CredentialDisplay>
</>
}
inactiveCredentials={
<>
<CredentialDisplay label="Access Key ID">
{inactiveCredentials?.accessKeyId}
</CredentialDisplay>
<CredentialDisplay isSensitive label="Secret Access Key">
{inactiveCredentials?.secretAccessKey}
</CredentialDisplay>
</>
}
/>
);
};
@@ -0,0 +1,41 @@
import { TLdapPasswordRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
import { CredentialDisplay, ViewRotationGeneratedCredentialsDisplay } from "./shared";
type Props = {
generatedCredentialsResponse: TLdapPasswordRotationGeneratedCredentialsResponse;
};
export const ViewLdapPasswordRotationGeneratedCredentials = ({
generatedCredentialsResponse: { generatedCredentials, activeIndex }
}: Props) => {
const inactiveIndex = activeIndex === 0 ? 1 : 0;
const activeCredentials = generatedCredentials[activeIndex];
const inactiveCredentials = generatedCredentials[inactiveIndex];
return (
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Distinguished Name (DN)">
{activeCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{activeCredentials?.password}
</CredentialDisplay>
</>
}
inactiveCredentials={
<>
<CredentialDisplay label="Distinguished Name (DN)">
{inactiveCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password}
</CredentialDisplay>
</>
}
/>
);
};
@@ -4,8 +4,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ViewAuth0ClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials";
import { ViewLdapPasswordRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials";
import { Modal, ModalContent, Spinner } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import {
IS_ROTATION_DUAL_CREDENTIALS,
SECRET_ROTATION_CONNECTION_MAP,
SECRET_ROTATION_MAP
} from "@app/helpers/secretRotationsV2";
import {
SecretRotation,
TSecretRotationV2,
@@ -13,6 +20,7 @@ import {
} from "@app/hooks/api/secretRotationsV2";
import { ViewSqlCredentialsRotationGeneratedCredentials } from "./shared";
import { ViewAwsIamUserSecretRotationGeneratedCredentials } from "./ViewAwsIamUserSecretRotationGeneratedCredentials";
type Props = {
secretRotation?: TSecretRotationV2;
@@ -67,13 +75,46 @@ const Content = ({ secretRotation }: ContentProps) => {
/>
);
break;
case SecretRotation.LdapPassword:
Component = (
<ViewLdapPasswordRotationGeneratedCredentials
generatedCredentialsResponse={generatedCredentialsResponse}
/>
);
break;
case SecretRotation.AwsIamUserSecret:
Component = (
<ViewAwsIamUserSecretRotationGeneratedCredentials
generatedCredentialsResponse={generatedCredentialsResponse}
/>
);
break;
default:
throw new Error("Unhandled View Generated Credential Rotation Type");
}
const appName = APP_CONNECTION_MAP[SECRET_ROTATION_CONNECTION_MAP[type]].name;
return (
<div className="flex flex-col gap-y-4">
{Component}
{!IS_ROTATION_DUAL_CREDENTIALS[type] && (
<NoticeBannerV2 title={`${appName} Retired Credentials Behavior`}>
<p className="text-sm text-mineshaft-300">
Due to {SECRET_ROTATION_MAP[type].name} Rotations utilizing a single credential set,
retired credentials will not be able to authenticate with {appName} during their{" "}
<a
target="_blank"
href="https://infisical.com/docs/documentation/platform/secret-rotation/overview#how-rotation-works"
rel="noopener noreferrer"
className="underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
>
inactive period
</a>
. This is a limitation of {appName} and cannot be rectified by Infisical.
</p>
</NoticeBannerV2>
)}
{nextRotationAt && (
<div className="flex items-center gap-x-1.5 text-sm text-mineshaft-200">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
@@ -0,0 +1,84 @@
import { Controller, useFormContext } from "react-hook-form";
import { SingleValue } from "react-select";
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { AwsRegionSelect } from "@app/components/secret-syncs/forms/SecretSyncDestinationFields/shared";
import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
import { TAwsIamUserSecret, useListAwsConnectionIamUsers } from "@app/hooks/api/appConnections/aws";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const AwsIamUserSecretRotationParametersFields = () => {
const { control, watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.AwsIamUserSecret;
}
>();
const connectionId = watch("connection.id");
const { data: clients, isPending: isClientsPending } = useListAwsConnectionIamUsers({
connectionId
});
return (
<>
<Controller
name="parameters.userName"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="IAM User"
helperText={
<Tooltip
className="max-w-md"
content={<>Ensure that your connection has the correct permissions.</>}
>
<div>
<span>Don&#39;t see the IAM user you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isClientsPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={clients?.find((client) => client.UserName === value) ?? ""}
onChange={(option) => {
onChange((option as SingleValue<TAwsIamUserSecret>)?.UserName ?? "");
}}
options={clients}
placeholder="Select an IAM user..."
getOptionLabel={(option) =>
(option as SingleValue<TAwsIamUserSecret>)?.UserName ?? ""
}
getOptionValue={(option) =>
(option as SingleValue<TAwsIamUserSecret>)?.UserName ?? ""
}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.region"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isOptional
isError={Boolean(error)}
errorText={error?.message}
label="Region"
tooltipText="Required only if no global scope is set."
>
<AwsRegionSelect value={value ?? ""} onChange={onChange} />
</FormControl>
)}
/>
</>
);
};
@@ -0,0 +1,169 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { FormControl, Input } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const LdapPasswordRotationParametersFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.LdapPassword;
}
>();
return (
<>
<Controller
name="parameters.dn"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Distinguished Name (DN)"
>
<Input
value={value}
onChange={onChange}
placeholder="CN=John,OU=Users,DC=example,DC=com"
/>
</FormControl>
)}
/>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Password Requirements</span>
</div>
<div className="grid grid-cols-2 gap-3 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 py-2">
<Controller
control={control}
name="parameters.passwordRequirements.length"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.length}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
helperText="The length of the password to generate"
>
<Input
type="number"
min={1}
max={250}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.digits"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.digits}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.lowercase"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.lowercase}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase characters"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.uppercase"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.uppercase}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase characters"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.symbols"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.symbols}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.allowedSymbols"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.allowedSymbols}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Symbols"
isError={Boolean(error)}
errorText={error?.message}
helperText="Symbols to use in generated password"
>
<Input
placeholder="-_.~!*"
size="sm"
{...field}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
</div>
</>
);
};
@@ -4,12 +4,16 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { TSecretRotationV2Form } from "../schemas";
import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields";
import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields";
import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields";
import { SqlCredentialsRotationParametersFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields,
[SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields,
[SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields
[SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields
};
export const SecretRotationV2ParametersFields = () => {
@@ -0,0 +1,30 @@
import { useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { SecretRotationReviewSection } from "./shared";
export const AwsIamUserSecretRotationReviewFields = () => {
const { watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.AwsIamUserSecret;
}
>();
const [parameters, { accessKeyId, secretAccessKey }] = watch(["parameters", "secretsMapping"]);
return (
<>
<SecretRotationReviewSection label="Parameters">
<GenericFieldLabel label="Region">{parameters.region}</GenericFieldLabel>
<GenericFieldLabel label="User Name">{parameters.userName}</GenericFieldLabel>
</SecretRotationReviewSection>
<SecretRotationReviewSection label="Secrets Mapping">
<GenericFieldLabel label="Secret Access Key ID">{accessKeyId}</GenericFieldLabel>
<GenericFieldLabel label="Secret Access Key">{secretAccessKey}</GenericFieldLabel>
</SecretRotationReviewSection>
</>
);
};
@@ -0,0 +1,29 @@
import { useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { SecretRotationReviewSection } from "./shared";
export const LdapPasswordRotationReviewFields = () => {
const { watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.LdapPassword;
}
>();
const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]);
return (
<>
<SecretRotationReviewSection label="Parameters">
<GenericFieldLabel label="Distinguished Name (DN)">{parameters.dn}</GenericFieldLabel>
</SecretRotationReviewSection>
<SecretRotationReviewSection label="Secrets Mapping">
<GenericFieldLabel label="Distinguished Name (DN)">{dn}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</SecretRotationReviewSection>
</>
);
};
@@ -7,12 +7,16 @@ import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields";
import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields";
import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields";
import { SqlCredentialsRotationReviewFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields,
[SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields,
[SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields
[SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields
};
export const SecretRotationV2ReviewFields = () => {
@@ -0,0 +1,58 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
import { SecretsMappingTable } from "./shared";
export const AwsIamUserSecretRotationSecretsMappingFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.AwsIamUserSecret;
}
>();
const { rotationOption } = useSecretRotationV2Option(SecretRotation.AwsIamUserSecret);
const items = [
{
name: "Access Key ID",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
value={value}
onChange={onChange}
placeholder={rotationOption?.template.secretsMapping.accessKeyId}
/>
</FormControl>
)}
control={control}
name="secretsMapping.accessKeyId"
/>
)
},
{
name: "Secret Access Key",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
value={value}
onChange={onChange}
placeholder={rotationOption?.template.secretsMapping.secretAccessKey}
/>
</FormControl>
)}
control={control}
name="secretsMapping.secretAccessKey"
/>
)
}
];
return <SecretsMappingTable items={items} />;
};
@@ -0,0 +1,58 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
import { SecretsMappingTable } from "./shared";
export const LdapPasswordRotationSecretsMappingFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.LdapPassword;
}
>();
const { rotationOption } = useSecretRotationV2Option(SecretRotation.LdapPassword);
const items = [
{
name: "DN",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
value={value}
onChange={onChange}
placeholder={rotationOption?.template.secretsMapping.dn}
/>
</FormControl>
)}
control={control}
name="secretsMapping.dn"
/>
)
},
{
name: "Password",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
value={value}
onChange={onChange}
placeholder={rotationOption?.template.secretsMapping.password}
/>
</FormControl>
)}
control={control}
name="secretsMapping.password"
/>
)
}
];
return <SecretsMappingTable items={items} />;
};
@@ -4,12 +4,16 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { TSecretRotationV2Form } from "../schemas";
import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields";
import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields";
import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields";
import { SqlCredentialsRotationSecretsMappingFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields,
[SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields,
[SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields
[SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields
};
export const SecretRotationV2SecretsMappingFields = () => {
@@ -0,0 +1,18 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const AwsIamUserSecretRotationSchema = z
.object({
type: z.literal(SecretRotation.AwsIamUserSecret),
parameters: z.object({
userName: z.string().trim().min(1, "User Name required"),
region: z.string().trim().optional()
}),
secretsMapping: z.object({
accessKeyId: z.string().trim().min(1, "Access Key ID required"),
secretAccessKey: z.string().trim().min(1, "Secret Access Key required")
})
})
.merge(BaseSecretRotationSchema);
@@ -1,13 +1,17 @@
import { z } from "zod";
import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/auth0-client-secret-rotation-schema";
import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema";
import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema";
import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
const SecretRotationUnionSchema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema,
Auth0ClientSecretRotationSchema
Auth0ClientSecretRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema
]);
export const SecretRotationV2FormSchema = SecretRotationUnionSchema;
@@ -0,0 +1,24 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { DistinguishedNameRegex } from "@app/helpers/string";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const LdapPasswordRotationSchema = z
.object({
type: z.literal(SecretRotation.LdapPassword),
parameters: z.object({
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
passwordRequirements: PasswordRequirementsSchema.optional()
}),
secretsMapping: z.object({
dn: z.string().trim().min(1, "Distinguished Name (DN) required"),
password: z.string().trim().min(1, "Password required")
})
})
.merge(BaseSecretRotationSchema);
@@ -1 +1,2 @@
export * from "./password-requirements-schema";
export * from "./sql-credentials-rotation-schema";
@@ -0,0 +1,47 @@
import { z } from "zod";
export const PasswordRequirementsSchema = z
.object({
length: z
.number()
.min(1, "Password length must be a positive number")
.max(250, "Password length must be less than 250"),
required: z.object({
digits: z.number().min(0, "Digit count must be non-negative"),
lowercase: z.number().min(0, "Lowercase count must be non-negative"),
uppercase: z.number().min(0, "Uppercase count must be non-negative"),
symbols: z.number().min(0, "Symbol count must be non-negative")
}),
allowedSymbols: z
.string()
.regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/, "Invalid symbols")
.optional()
.transform((value) => value || "-_.~!*")
})
.refine(
(data) => {
return Object.values(data.required).some((count) => count > 0);
},
{
message: "At least one character type must be required",
path: ["required.digits"]
}
)
.refine(
(data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
},
{ message: "Sum of required characters cannot exceed the total length", path: ["length"] }
);
export const DEFAULT_PASSWORD_REQUIREMENTS = {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
};
@@ -12,6 +12,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields";
import { GcpSyncFields } from "./GcpSyncFields";
import { GitHubSyncFields } from "./GitHubSyncFields";
import { HumanitecSyncFields } from "./HumanitecSyncFields";
import { TeamCitySyncFields } from "./TeamCitySyncFields";
import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields";
import { VercelSyncFields } from "./VercelSyncFields";
import { WindmillSyncFields } from "./WindmillSyncFields";
@@ -46,6 +47,8 @@ export const SecretSyncDestinationFields = () => {
return <VercelSyncFields />;
case SecretSync.Windmill:
return <WindmillSyncFields />;
case SecretSync.TeamCity:
return <TeamCitySyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
@@ -0,0 +1,128 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
import {
TTeamCityProjectWithBuildTypes,
useTeamCityConnectionListProjects
} from "@app/hooks/api/appConnections/teamcity";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const TeamCitySyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.TeamCity }
>();
const connectionId = useWatch({ name: "connection.id", control });
const { data: projects, isLoading: isProjectsLoading } = useTeamCityConnectionListProjects(
connectionId,
{
enabled: Boolean(connectionId)
}
);
// For Build Config dropdown
const selectedProjectId = useWatch({ name: "destinationConfig.project", control });
const selectedProject = projects?.find((proj) => proj.id === selectedProjectId);
const buildTypes = selectedProject?.buildTypes?.buildType || [];
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.project", "");
setValue("destinationConfig.buildConfig", "");
}}
/>
<Controller
name="destinationConfig.project"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project"
helperText={
<Tooltip
className="max-w-md"
content="Ensure the project exists in the connection's TeamCity instance URL."
>
<div>
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isProjectsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={projects?.find((proj) => proj.id === value) ?? null}
onChange={(option) => {
onChange((option as SingleValue<TTeamCityProjectWithBuildTypes>)?.id ?? null);
setValue("destinationConfig.buildConfig", "");
}}
options={projects}
placeholder="Select a project..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.buildConfig"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
isOptional
label="Build Configuration"
helperText={
<Tooltip
className="max-w-md"
content='Ensure the configuration exists in the selected project and that your Access Token has the "View build configuration settings" permission.'
>
<div>
<span>Don&#39;t see the configuration you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isProjectsLoading && Boolean(connectionId)}
isDisabled={!connectionId || !selectedProject}
value={buildTypes.find((buildType) => buildType.id === value) ?? null}
onChange={(option) => {
const selectedOption = option as SingleValue<{ id: string; name: string }>;
onChange(selectedOption?.id ?? "");
}}
options={buildTypes}
isClearable
placeholder="Select a build configuration..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
<span className="text-sm text-bunker-300">
Not selecting a Build Configuration will sync your secrets to the entire project.
</span>
</>
);
};
@@ -43,6 +43,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.Camunda:
case SecretSync.Vercel:
case SecretSync.Windmill:
case SecretSync.TeamCity:
AdditionalSyncOptionsFieldsComponent = null;
break;
default:
@@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields";
import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields";
import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields";
import { VercelSyncReviewFields } from "./VercelSyncReviewFields";
import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields";
@@ -88,6 +89,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.Windmill:
DestinationFieldsComponent = <WindmillSyncReviewFields />;
break;
case SecretSync.TeamCity:
DestinationFieldsComponent = <TeamCitySyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
@@ -0,0 +1,18 @@
import { useFormContext } from "react-hook-form";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const TeamCitySyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.TeamCity }>();
const project = watch("destinationConfig.project");
const buildConfig = watch("destinationConfig.buildConfig");
return (
<>
<GenericFieldLabel label="Project">{project}</GenericFieldLabel>
<GenericFieldLabel label="Build Configuration">{buildConfig}</GenericFieldLabel>
</>
);
};
@@ -9,6 +9,7 @@ import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-s
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema";
import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema";
import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema";
import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema";
import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema";
import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema";
@@ -25,7 +26,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
TerraformCloudSyncDestinationSchema,
CamundaSyncDestinationSchema,
VercelSyncDestinationSchema,
WindmillSyncDestinationSchema
WindmillSyncDestinationSchema,
TeamCitySyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema;
@@ -0,0 +1,14 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const TeamCitySyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.TeamCity),
destinationConfig: z.object({
project: z.string().trim().min(1, "Project required"),
buildConfig: z.string().trim().optional()
})
})
);
@@ -0,0 +1,113 @@
import { ReactNode, useEffect, useState } from "react";
import { useToggle } from "@app/hooks";
import { Button } from "../Button";
import { FormControl } from "../FormControl";
import { Input } from "../Input";
import { Modal, ModalClose, ModalContent } from "../Modal";
type Props = {
isOpen?: boolean;
onClose?: () => void;
onChange?: (isOpen: boolean) => void;
confirmKey: string;
title: string;
subTitle?: string;
onConfirmed: () => Promise<void>;
buttonText?: string;
formContent?: ReactNode;
children?: ReactNode;
confirmationMessage?: ReactNode;
};
export const ConfirmActionModal = ({
isOpen,
onClose,
onChange,
confirmKey,
onConfirmed,
title,
subTitle = "This action is irreversible.",
buttonText = "Yes",
formContent,
confirmationMessage,
children
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
const [isLoading, setIsLoading] = useToggle();
useEffect(() => {
setInputData("");
}, [isOpen]);
const onDelete = async () => {
setIsLoading.on();
try {
await onConfirmed();
} finally {
setIsLoading.off();
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(isOpenState) => {
setInputData("");
if (onChange) onChange(isOpenState);
}}
>
<ModalContent
title={title}
subTitle={subTitle}
footerContent={
<div className="mx-2 flex items-center">
<Button
className="mr-4"
isDisabled={!(confirmKey === inputData) || isLoading}
onClick={onDelete}
isLoading={isLoading}
>
{buttonText}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</ModalClose>
</div>
}
onClose={onClose}
>
{formContent}
<form
onSubmit={(evt) => {
evt.preventDefault();
if (confirmKey === inputData) onDelete();
}}
>
<FormControl
label={
<div className="break-words pb-2 text-sm">
{confirmationMessage || (
<>
Type <span className="font-bold">{confirmKey}</span> to perform this action
</>
)}
</div>
}
className="mb-0"
>
<Input
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder={`Type ${confirmKey} here`}
/>
</FormControl>
{children}
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { ConfirmActionModal } from "./ConfirmActionModal";
@@ -14,7 +14,7 @@ export const PageHeader = ({ title, description, children, className }: Props) =
<div className="w-full">
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
</div>
<div className="flex items-center">{children}</div>
<div className="flex items-center gap-2">{children}</div>
</div>
<div className="mt-2 text-gray-400">{description}</div>
</div>
+1
View File
@@ -6,6 +6,7 @@ export * from "./Breadcrumb";
export * from "./Button";
export * from "./Card";
export * from "./Checkbox";
export * from "./ConfirmActionModal";
export * from "./ContentLoader";
export * from "./DatePicker";
export * from "./DeleteActionModal";
@@ -14,12 +14,13 @@ export const useProjectPermission = () => {
strict: false,
select: (el) => el?.projectId
});
if (!projectId) {
throw new Error("useProjectPermission to be used within <ProjectPermissionContext>");
}
const {
data: { permission, membership }
data: { permission, membership, assumedPrivilegeDetails }
} = useSuspenseQuery({
queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }),
queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }),
@@ -29,6 +30,7 @@ export const useProjectPermission = () => {
const ability = evaluatePermissionsAbility(rule);
return {
permission: ability,
assumedPrivilegeDetails: data.assumedPrivilegeDetails,
membership: {
...data.membership,
roles: data.membership.roles.map(({ role }) => role)
@@ -42,5 +44,5 @@ export const useProjectPermission = () => {
[]
);
return { permission, membership, hasProjectRole };
return { permission, membership, hasProjectRole, assumedPrivilegeDetails };
};
@@ -58,7 +58,8 @@ export enum ProjectPermissionIdentityActions {
Create = "create",
Edit = "edit",
Delete = "delete",
GrantPrivileges = "grant-privileges"
GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
}
export enum ProjectPermissionMemberActions {
@@ -66,7 +67,8 @@ export enum ProjectPermissionMemberActions {
Create = "create",
Edit = "edit",
Delete = "delete",
GrantPrivileges = "grant-privileges"
GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
}
export enum ProjectPermissionGroupActions {
@@ -247,7 +249,7 @@ export type ProjectPermissionSet =
]
| [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member]
| [ProjectPermissionMemberActions, ProjectPermissionSub.Member]
| [ProjectPermissionActions, ProjectPermissionSub.Groups]
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
@@ -258,7 +260,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [
ProjectPermissionActions,
ProjectPermissionIdentityActions,
(
| ProjectPermissionSub.Identity
| (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
+16 -2
View File
@@ -1,5 +1,12 @@
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faKey, faLock, faPassport, faServer, faUser } from "@fortawesome/free-solid-svg-icons";
import {
faKey,
faLink,
faLock,
faPassport,
faServer,
faUser
} from "@fortawesome/free-solid-svg-icons";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
@@ -12,9 +19,11 @@ import {
GcpConnectionMethod,
GitHubConnectionMethod,
HumanitecConnectionMethod,
LdapConnectionMethod,
MsSqlConnectionMethod,
PostgresConnectionMethod,
TAppConnection,
TeamCityConnectionMethod,
TerraformCloudConnectionMethod,
VercelConnectionMethod,
WindmillConnectionMethod
@@ -43,7 +52,9 @@ export const APP_CONNECTION_MAP: Record<
[AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" },
[AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" },
[AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" },
[AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }
[AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 },
[AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 },
[AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -71,10 +82,13 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
return { name: "Username & Password", icon: faLock };
case TeamCityConnectionMethod.AccessToken:
case WindmillConnectionMethod.AccessToken:
return { name: "Access Token", icon: faKey };
case Auth0ConnectionMethod.ClientCredentials:
return { name: "Client Credentials", icon: faServer };
case LdapConnectionMethod.SimpleBind:
return { name: "Simple Bind", icon: faLink };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}
+16 -2
View File
@@ -19,20 +19,34 @@ export const SECRET_ROTATION_MAP: Record<
name: "Auth0 Client Secret",
image: "Auth0.png",
size: 35
},
[SecretRotation.LdapPassword]: {
name: "LDAP Password",
image: "LDAP.png",
size: 65
},
[SecretRotation.AwsIamUserSecret]: {
name: "AWS IAM User Secret",
image: "Amazon Web Services.png",
size: 50
}
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
[SecretRotation.PostgresCredentials]: AppConnection.Postgres,
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql,
[SecretRotation.Auth0ClientSecret]: AppConnection.Auth0
[SecretRotation.Auth0ClientSecret]: AppConnection.Auth0,
[SecretRotation.LdapPassword]: AppConnection.LDAP,
[SecretRotation.AwsIamUserSecret]: AppConnection.AWS
};
// if a rotation can potentially have downtime due to rotating a single credential set this to false
export const IS_ROTATION_DUAL_CREDENTIALS: Record<SecretRotation, boolean> = {
[SecretRotation.PostgresCredentials]: true,
[SecretRotation.MsSqlCredentials]: true,
[SecretRotation.Auth0ClientSecret]: false
[SecretRotation.Auth0ClientSecret]: false,
[SecretRotation.LdapPassword]: false,
[SecretRotation.AwsIamUserSecret]: true
};
export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => {
+6 -1
View File
@@ -39,6 +39,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
[SecretSync.Windmill]: {
name: "Windmill",
image: "Windmill.png"
},
[SecretSync.TeamCity]: {
name: "TeamCity",
image: "TeamCity.png"
}
};
@@ -54,7 +58,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.TerraformCloud]: AppConnection.TerraformCloud,
[SecretSync.Camunda]: AppConnection.Camunda,
[SecretSync.Vercel]: AppConnection.Vercel,
[SecretSync.Windmill]: AppConnection.Windmill
[SecretSync.Windmill]: AppConnection.Windmill,
[SecretSync.TeamCity]: AppConnection.TeamCity
};
export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record<
+3
View File
@@ -12,3 +12,6 @@ export const isValidPath = (val: string): boolean => {
const validPathRegex = /^[a-zA-Z0-9-_.:]+(?:\/[a-zA-Z0-9-_.:]+)*$/;
return validPathRegex.test(val);
};
export const DistinguishedNameRegex =
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/;
@@ -4,15 +4,20 @@ import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "@app/hooks/api/appConnections";
import {
TAwsConnectionIamUser,
TAwsConnectionKmsKey,
TAwsConnectionListIamUsersResponse,
TAwsConnectionListKmsKeysResponse,
TListAwsConnectionIamUsers,
TListAwsConnectionKmsKeys
} from "./types";
const awsConnectionKeys = {
all: [...appConnectionKeys.all, "aws"] as const,
listKmsKeys: (params: TListAwsConnectionKmsKeys) =>
[...awsConnectionKeys.all, "kms-keys", params] as const
[...awsConnectionKeys.all, "kms-keys", params] as const,
listIamUsers: (params: TListAwsConnectionIamUsers) =>
[...awsConnectionKeys.all, "iam-users", params] as const
};
export const useListAwsConnectionKmsKeys = (
@@ -40,3 +45,28 @@ export const useListAwsConnectionKmsKeys = (
...options
});
};
export const useListAwsConnectionIamUsers = (
{ connectionId }: TListAwsConnectionIamUsers,
options?: Omit<
UseQueryOptions<
TAwsConnectionIamUser[],
unknown,
TAwsConnectionIamUser[],
ReturnType<typeof awsConnectionKeys.listIamUsers>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: awsConnectionKeys.listIamUsers({ connectionId }),
queryFn: async () => {
const { data } = await apiRequest.get<TAwsConnectionListIamUsersResponse>(
`/api/v1/app-connections/aws/${connectionId}/users`
);
return data.iamUsers;
},
...options
});
};
@@ -14,3 +14,18 @@ export type TAwsConnectionKmsKey = {
export type TAwsConnectionListKmsKeysResponse = {
kmsKeys: TAwsConnectionKmsKey[];
};
export type TListAwsConnectionIamUsers = {
connectionId: string;
};
export type TAwsConnectionIamUser = {
arn: string;
UserName: string;
};
export type TAwsConnectionListIamUsersResponse = {
iamUsers: TAwsConnectionIamUser[];
};
export type TAwsIamUserSecret = TAwsConnectionIamUser;
@@ -12,5 +12,7 @@ export enum AppConnection {
MsSql = "mssql",
Camunda = "camunda",
Windmill = "windmill",
Auth0 = "auth0"
Auth0 = "auth0",
LDAP = "ldap",
TeamCity = "teamcity"
}
@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";
@@ -0,0 +1,37 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries";
import { TTeamCityProjectWithBuildTypes } from "./types";
const teamcityConnectionKeys = {
all: [...appConnectionKeys.all, "teamcity"] as const,
listProjects: (connectionId: string) =>
[...teamcityConnectionKeys.all, "projects", connectionId] as const
};
export const useTeamCityConnectionListProjects = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TTeamCityProjectWithBuildTypes[],
unknown,
TTeamCityProjectWithBuildTypes[],
ReturnType<typeof teamcityConnectionKeys.listProjects>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: teamcityConnectionKeys.listProjects(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TTeamCityProjectWithBuildTypes[]>(
`/api/v1/app-connections/teamcity/${connectionId}/projects`
);
return data;
},
...options
});
};
@@ -0,0 +1,13 @@
export type TTeamCityProject = {
id: string;
name: string;
};
export type TTeamCityProjectWithBuildTypes = TTeamCityProject & {
buildTypes: {
buildType: {
id: string;
name: string;
}[];
};
};
@@ -67,6 +67,14 @@ export type TAuth0ConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Auth0;
};
export type TLdapConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.LDAP;
};
export type TTeamCityConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.TeamCity;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -81,7 +89,8 @@ export type TAppConnectionOption =
| TMsSqlConnectionOption
| TCamundaConnectionOption
| TWindmillConnectionOption
| TAuth0ConnectionOption;
| TAuth0ConnectionOption
| TTeamCityConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -98,4 +107,6 @@ export type TAppConnectionOptionMap = {
[AppConnection.Camunda]: TCamundaConnectionOption;
[AppConnection.Windmill]: TWindmillConnectionOption;
[AppConnection.Auth0]: TAuth0ConnectionOption;
[AppConnection.LDAP]: TLdapConnectionOption;
[AppConnection.TeamCity]: TTeamCityConnectionOption;
};
@@ -9,8 +9,10 @@ import { TDatabricksConnection } from "./databricks-connection";
import { TGcpConnection } from "./gcp-connection";
import { TGitHubConnection } from "./github-connection";
import { THumanitecConnection } from "./humanitec-connection";
import { TLdapConnection } from "./ldap-connection";
import { TMsSqlConnection } from "./mssql-connection";
import { TPostgresConnection } from "./postgres-connection";
import { TTeamCityConnection } from "./teamcity-connection";
import { TTerraformCloudConnection } from "./terraform-cloud-connection";
import { TVercelConnection } from "./vercel-connection";
import { TWindmillConnection } from "./windmill-connection";
@@ -24,8 +26,10 @@ export * from "./databricks-connection";
export * from "./gcp-connection";
export * from "./github-connection";
export * from "./humanitec-connection";
export * from "./ldap-connection";
export * from "./mssql-connection";
export * from "./postgres-connection";
export * from "./teamcity-connection";
export * from "./terraform-cloud-connection";
export * from "./vercel-connection";
export * from "./windmill-connection";
@@ -44,7 +48,9 @@ export type TAppConnection =
| TMsSqlConnection
| TCamundaConnection
| TWindmillConnection
| TAuth0Connection;
| TAuth0Connection
| TLdapConnection
| TTeamCityConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -86,4 +92,6 @@ export type TAppConnectionMap = {
[AppConnection.Camunda]: TCamundaConnection;
[AppConnection.Windmill]: TWindmillConnection;
[AppConnection.Auth0]: TAuth0Connection;
[AppConnection.LDAP]: TLdapConnection;
[AppConnection.TeamCity]: TTeamCityConnection;
};
@@ -0,0 +1,21 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum LdapConnectionMethod {
SimpleBind = "simple-bind"
}
export enum LdapConnectionProvider {
ActiveDirectory = "active-directory"
}
export type TLdapConnection = TRootAppConnection & { app: AppConnection.LDAP } & {
method: LdapConnectionMethod.SimpleBind;
credentials: {
provider: LdapConnectionProvider;
url: string;
dn: string;
sslRejectUnauthorized?: boolean;
sslCertificate?: string;
};
};
@@ -0,0 +1,14 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum TeamCityConnectionMethod {
AccessToken = "access-token"
}
export type TTeamCityConnection = TRootAppConnection & { app: AppConnection.TeamCity } & {
method: TeamCityConnectionMethod.AccessToken;
credentials: {
accessToken: string;
instanceUrl: string;
};
};
@@ -0,0 +1 @@
export { useAssumeProjectPrivileges, useRemoveAssumeProjectPrivilege } from "./mutations";
@@ -0,0 +1,28 @@
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TProjectAssumePrivilegesDTO } from "./types";
export const useAssumeProjectPrivileges = () =>
useMutation({
mutationFn: async ({ projectId, actorId, actorType }: TProjectAssumePrivilegesDTO) => {
const { data } = await apiRequest.post<{ message: string }>(
`/api/v1/workspace/${projectId}/assume-privileges`,
{ actorId, actorType }
);
return data;
}
});
export const useRemoveAssumeProjectPrivilege = () =>
useMutation({
mutationFn: async ({ projectId }: { projectId: string }) => {
const { data } = await apiRequest.delete<{ message: string }>(
`/api/v1/workspace/${projectId}/assume-privileges`
);
return data;
}
});
@@ -0,0 +1,7 @@
import { ActorType } from "../auditLogs/enums";
export type TProjectAssumePrivilegesDTO = {
projectId: string;
actorType: ActorType;
actorId: string;
};
@@ -84,6 +84,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.ADD_PKI_COLLECTION_ITEM]: "Add PKI collection item",
[EventType.DELETE_PKI_COLLECTION_ITEM]: "Delete PKI collection item",
[EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project",
[EventType.ORG_ADMIN_BYPASS_SSO]: "Org admin bypassed SSO enforcement",
[EventType.CREATE_CERTIFICATE_TEMPLATE]: "Create certificate template",
[EventType.UPDATE_CERTIFICATE_TEMPLATE]: "Update certificate template",
[EventType.DELETE_CERTIFICATE_TEMPLATE]: "Delete certificate template",
@@ -90,6 +90,7 @@ export enum EventType {
ADD_PKI_COLLECTION_ITEM = "add-pki-collection-item",
DELETE_PKI_COLLECTION_ITEM = "delete-pki-collection-item",
ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project",
ORG_ADMIN_BYPASS_SSO = "org-admin-bypassed-sso",
CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template",
UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template",
DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template",
@@ -718,6 +718,11 @@ interface OrgAdminAccessProjectEvent {
}; // no metadata yet
}
interface OrgAdminBypassSSOEvent {
type: EventType.ORG_ADMIN_BYPASS_SSO;
metadata: Record<string, string>; // no metadata yet
}
interface CreateCertificateTemplate {
type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata: {
@@ -885,6 +890,7 @@ export type Event =
| AddPkiCollectionItem
| DeletePkiCollectionItem
| OrgAdminAccessProjectEvent
| OrgAdminBypassSSOEvent
| CreateCertificateTemplate
| UpdateCertificateTemplate
| GetCertificateTemplate
+1
View File
@@ -1,6 +1,7 @@
export * from "./accessApproval";
export * from "./admin";
export * from "./apiKeys";
export * from "./assumePrivileges";
export * from "./auditLogs";
export * from "./auditLogStreams";
export * from "./auth";
+7
View File
@@ -10,6 +10,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/type
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
import { ActorType } from "../auditLogs/enums";
import { OrgUser, TProjectMembership } from "../users/types";
import {
TGetUserOrgPermissionsDTO,
@@ -137,6 +138,12 @@ export const fetchUserProjectPermissions = async ({
data: {
permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[];
membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] };
assumedPrivilegeDetails?: {
actorId: string;
actorType: ActorType;
actorEmail: string;
actorName: string;
};
};
}>(`/api/v1/workspace/${workspaceId}/permissions`, {});
@@ -1,7 +1,9 @@
export enum SecretRotation {
PostgresCredentials = "postgres-credentials",
MsSqlCredentials = "mssql-credentials",
Auth0ClientSecret = "auth0-client-secret"
Auth0ClientSecret = "auth0-client-secret",
LdapPassword = "ldap-password",
AwsIamUserSecret = "aws-iam-user-secret"
}
export enum SecretRotationStatus {
@@ -0,0 +1,38 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
TSecretRotationV2Base,
TSecretRotationV2GeneratedCredentialsResponseBase
} from "@app/hooks/api/secretRotationsV2/types/shared";
export type TAwsIamUserSecretRotation = TSecretRotationV2Base & {
type: SecretRotation.AwsIamUserSecret;
parameters: {
region?: string;
userName: string;
};
secretsMapping: {
accessKeyId: string;
secretAccessKey: string;
};
};
export type TAwsIamUserSecretRotationGeneratedCredentials = {
accessKeyId: string;
secretAccessKey: string;
};
export type TAwsIamUserSecretRotationGeneratedCredentialsResponse =
TSecretRotationV2GeneratedCredentialsResponseBase<
SecretRotation.AwsIamUserSecret,
TAwsIamUserSecretRotationGeneratedCredentials
>;
export type TAwsIamUserSecretRotationOption = {
name: string;
type: SecretRotation.AwsIamUserSecret;
connection: AppConnection.AWS;
template: {
secretsMapping: TAwsIamUserSecretRotation["secretsMapping"];
};
};
@@ -4,6 +4,16 @@ import {
TAuth0ClientSecretRotationGeneratedCredentialsResponse,
TAuth0ClientSecretRotationOption
} from "@app/hooks/api/secretRotationsV2/types/auth0-client-secret-rotation";
import {
TAwsIamUserSecretRotation,
TAwsIamUserSecretRotationGeneratedCredentialsResponse,
TAwsIamUserSecretRotationOption
} from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation";
import {
TLdapPasswordRotation,
TLdapPasswordRotationGeneratedCredentialsResponse,
TLdapPasswordRotationOption
} from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
import {
TMsSqlCredentialsRotation,
TMsSqlCredentialsRotationGeneratedCredentialsResponse
@@ -20,13 +30,17 @@ export type TSecretRotationV2 = (
| TPostgresCredentialsRotation
| TMsSqlCredentialsRotation
| TAuth0ClientSecretRotation
| TLdapPasswordRotation
| TAwsIamUserSecretRotation
) & {
secrets: (SecretV3RawSanitized | null)[];
};
export type TSecretRotationV2Option =
| TSqlCredentialsRotationOption
| TAuth0ClientSecretRotationOption;
| TAuth0ClientSecretRotationOption
| TLdapPasswordRotationOption
| TAwsIamUserSecretRotationOption;
export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] };
@@ -35,7 +49,9 @@ export type TSecretRotationV2Response = { secretRotation: TSecretRotationV2 };
export type TViewSecretRotationGeneratedCredentialsResponse =
| TPostgresCredentialsRotationGeneratedCredentialsResponse
| TMsSqlCredentialsRotationGeneratedCredentialsResponse
| TAuth0ClientSecretRotationGeneratedCredentialsResponse;
| TAuth0ClientSecretRotationGeneratedCredentialsResponse
| TLdapPasswordRotationGeneratedCredentialsResponse
| TAwsIamUserSecretRotationGeneratedCredentialsResponse;
export type TCreateSecretRotationV2DTO = DiscriminativePick<
TSecretRotationV2,
@@ -82,10 +98,14 @@ export type TSecretRotationOptionMap = {
[SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption;
[SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption;
[SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption;
[SecretRotation.LdapPassword]: TLdapPasswordRotationOption;
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption;
};
export type TSecretRotationGeneratedCredentialsResponseMap = {
[SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse;
[SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse;
[SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse;
[SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse;
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse;
};
@@ -0,0 +1,37 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
TSecretRotationV2Base,
TSecretRotationV2GeneratedCredentialsResponseBase
} from "@app/hooks/api/secretRotationsV2/types/shared";
export type TLdapPasswordRotation = TSecretRotationV2Base & {
type: SecretRotation.LdapPassword;
parameters: {
dn: string;
};
secretsMapping: {
dn: string;
password: string;
};
};
export type TLdapPasswordRotationGeneratedCredentials = {
dn: string;
password: string;
};
export type TLdapPasswordRotationGeneratedCredentialsResponse =
TSecretRotationV2GeneratedCredentialsResponseBase<
SecretRotation.LdapPassword,
TLdapPasswordRotationGeneratedCredentials
>;
export type TLdapPasswordRotationOption = {
name: string;
type: SecretRotation.LdapPassword;
connection: AppConnection.LDAP;
template: {
secretsMapping: TLdapPasswordRotation["secretsMapping"];
};
};
+2 -1
View File
@@ -10,7 +10,8 @@ export enum SecretSync {
TerraformCloud = "terraform-cloud",
Camunda = "camunda",
Vercel = "vercel",
Windmill = "windmill"
Windmill = "windmill",
TeamCity = "teamcity"
}
export enum SecretSyncStatus {
@@ -10,6 +10,7 @@ import { TDatabricksSync } from "./databricks-sync";
import { TGcpSync } from "./gcp-sync";
import { TGitHubSync } from "./github-sync";
import { THumanitecSync } from "./humanitec-sync";
import { TTeamCitySync } from "./teamcity-sync";
import { TTerraformCloudSync } from "./terraform-cloud-sync";
import { TVercelSync } from "./vercel-sync";
import { TWindmillSync } from "./windmill-sync";
@@ -32,7 +33,8 @@ export type TSecretSync =
| TTerraformCloudSync
| TCamundaSync
| TVercelSync
| TWindmillSync;
| TWindmillSync
| TTeamCitySync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };
@@ -0,0 +1,16 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TTeamCitySync = TRootSecretSync & {
destination: SecretSync.TeamCity;
destinationConfig: {
project: string;
buildConfig?: string;
};
connection: {
app: AppConnection.TeamCity;
name: string;
id: string;
};
};
@@ -83,6 +83,7 @@ export const useUpdateSecretV3 = ({
secretComment,
secretReminderRepeatDays,
secretReminderNote,
secretReminderRecipients,
newSecretName,
skipMultilineEncoding,
secretMetadata
@@ -93,6 +94,7 @@ export const useUpdateSecretV3 = ({
type,
secretReminderNote,
secretReminderRepeatDays,
secretReminderRecipients,
secretPath,
skipMultilineEncoding,
newSecretName,
@@ -80,6 +80,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
comment: el.secretComment || "",
reminderRepeatDays: el.secretReminderRepeatDays,
reminderNote: el.secretReminderNote,
secretReminderRecipients: el.secretReminderRecipients,
createdAt: el.createdAt,
updatedAt: el.updatedAt,
version: el.version,
+12
View File
@@ -7,6 +7,14 @@ export enum SecretType {
Personal = "personal"
}
export type SecretReminderRecipient = {
user: {
id: string;
username: string;
email: string;
};
id: string;
};
export type EncryptedSecret = {
id: string;
version: number;
@@ -42,6 +50,7 @@ export type SecretV3RawSanitized = {
comment?: string;
reminderRepeatDays?: number | null;
reminderNote?: string | null;
reminderRecipients?: string[];
tags?: WsTag[];
createdAt: string;
updatedAt: string;
@@ -55,6 +64,7 @@ export type SecretV3RawSanitized = {
secretMetadata?: { key: string; value: string }[];
isReminderEvent?: boolean;
isRotatedSecret?: boolean;
secretReminderRecipients?: SecretReminderRecipient[];
rotationId?: string;
};
@@ -80,6 +90,7 @@ export type SecretV3Raw = {
updatedAt: string;
isRotatedSecret?: boolean;
rotationId?: string;
secretReminderRecipients?: SecretReminderRecipient[];
};
export type SecretV3RawResponse = {
@@ -177,6 +188,7 @@ export type TUpdateSecretsV3DTO = {
secretReminderNote?: string | null;
tagIds?: string[];
secretMetadata?: { key: string; value: string }[];
secretReminderRecipients?: string[] | null;
};
export type TDeleteSecretsV3DTO = {
+3
View File
@@ -2,6 +2,7 @@ export type TSshHost = {
id: string;
projectId: string;
hostname: string;
alias: string | null;
userCertTtl: string;
hostCertTtl: string;
loginMappings: {
@@ -15,6 +16,7 @@ export type TSshHost = {
export type TCreateSshHostDTO = {
projectId: string;
hostname: string;
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings: {
@@ -28,6 +30,7 @@ export type TCreateSshHostDTO = {
export type TUpdateSshHostDTO = {
sshHostId: string;
hostname?: string;
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings?: {
+1
View File
@@ -1,6 +1,7 @@
export {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useRevokeMySessionById,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode
} from "./mutation";
+13
View File
@@ -171,3 +171,16 @@ export const useResendOrgMemberInvitation = () => {
}
});
};
export const useRevokeMySessionById = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (sessionId: string) => {
const { data } = await apiRequest.delete(`/api/v2/users/me/sessions/${sessionId}`);
return data;
},
onSuccess() {
queryClient.invalidateQueries({ queryKey: userKeys.mySessions });
}
});
};
+3 -1
View File
@@ -4,7 +4,8 @@ export {
useLeaveProject,
useMigrateProjectToV3,
useRequestProjectAccess,
useUpdateGroupWorkspaceRole
useUpdateGroupWorkspaceRole,
useUpdateProjectSshConfig
} from "./mutations";
export {
useAddIdentityToWorkspace,
@@ -14,6 +15,7 @@ export {
useDeleteUserFromWorkspace,
useDeleteWorkspace,
useDeleteWsEnvironment,
useGetProjectSshConfig,
useGetUpgradeProjectStatus,
useGetUserWorkspaceMemberships,
useGetUserWorkspaces,
+22 -1
View File
@@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request";
import { userKeys } from "../users/query-keys";
import { workspaceKeys } from "./query-keys";
import { TUpdateWorkspaceGroupRoleDTO } from "./types";
import {
TProjectSshConfig,
TUpdateProjectSshConfigDTO,
TUpdateWorkspaceGroupRoleDTO
} from "./types";
export const useAddGroupToWorkspace = () => {
const queryClient = useQueryClient();
@@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => {
}
});
};
export const useUpdateProjectSshConfig = () => {
const queryClient = useQueryClient();
return useMutation<TProjectSshConfig, object, TUpdateProjectSshConfigDTO>({
mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => {
return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, {
defaultUserSshCaId,
defaultHostSshCaId
});
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getProjectSshConfig(projectId)
});
}
});
};
+26 -3
View File
@@ -34,6 +34,7 @@ import {
TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO,
ToggleDeleteProjectProtectionDTO,
TProjectSshConfig,
TSearchProjectsDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
@@ -430,9 +431,13 @@ export const useDeleteWsEnvironment = () => {
});
};
export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: boolean) => {
export const useGetWorkspaceUsers = (
workspaceId: string,
includeGroupMembers?: boolean,
roles?: string[]
) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceUsers(workspaceId),
queryKey: workspaceKeys.getWorkspaceUsers(workspaceId, includeGroupMembers, roles),
queryFn: async () => {
const {
data: { users }
@@ -440,7 +445,11 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?:
`/api/v1/workspace/${workspaceId}/users`,
{
params: {
includeGroupMembers
includeGroupMembers,
roles:
roles && roles.length > 0
? roles.map((role) => encodeURIComponent(role)).join(",")
: undefined
}
}
);
@@ -887,3 +896,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin
enabled: Boolean(workspaceId)
});
};
export const useGetProjectSshConfig = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getProjectSshConfig(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<TProjectSshConfig>(
`/api/v1/workspace/${projectId}/ssh-config`
);
return data;
},
enabled: Boolean(projectId)
});
};
@@ -15,7 +15,8 @@ export const workspaceKeys = {
type ? ["workspaces", { type }] : (["workspaces"] as const),
getWorkspaceAuditLogs: (workspaceId: string) =>
[{ workspaceId }, "workspace-audit-logs"] as const,
getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const,
getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) =>
[{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const,
getWorkspaceUserDetails: (workspaceId: string, membershipId: string) =>
[{ workspaceId, membershipId }, "workspace-user-details"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) =>
@@ -69,5 +70,6 @@ export const workspaceKeys = {
projectId: string;
}) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const,
getWorkspaceSshCertificateTemplates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificate-templates"] as const
[{ projectId }, "workspace-ssh-certificate-templates"] as const,
getProjectSshConfig: (projectId: string) => [{ projectId }, "project-ssh-config"] as const
};
+15
View File
@@ -184,3 +184,18 @@ export type TSearchProjectsDTO = {
orderBy?: ProjectIdentityOrderBy;
orderDirection?: OrderByDirection;
};
export type TProjectSshConfig = {
id: string;
createdAt: string;
updatedAt: string;
projectId: string;
defaultUserSshCaId: string | null;
defaultHostSshCaId: string | null;
};
export type TUpdateProjectSshConfigDTO = {
projectId: string;
defaultUserSshCaId?: string;
defaultHostSshCaId?: string;
};
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
BreadcrumbContainer,
Menu,
@@ -11,7 +12,13 @@ import {
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import {
useProjectPermission,
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import {
useGetAccessRequestsCount,
useGetSecretApprovalRequestCount,
@@ -19,6 +26,7 @@ import {
} from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { AssumePrivilegeModeBanner } from "./components/AssumePrivilegeModeBanner";
import { ProjectSelect } from "./components/ProjectSelect";
// This is a generic layout shared by all types of projects.
@@ -29,6 +37,7 @@ export const ProjectLayout = () => {
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
const { t } = useTranslation();
const { assumedPrivilegeDetails } = useProjectPermission();
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const { subscription } = useSubscription();
@@ -62,6 +71,7 @@ export const ProjectLayout = () => {
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<motion.div
key="menu-project-items"
@@ -187,22 +197,31 @@ export const ProjectLayout = () => {
</MenuItem>
)}
</Link> */}
{/* <Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link> */}
{(isAllowed) =>
isAllowed && (
<Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link>
)
}
</ProjectPermissionCan>
</>
)}
{isSecretManager && (
@@ -0,0 +1,49 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { useRemoveAssumeProjectPrivilege } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
export const AssumePrivilegeModeBanner = () => {
const { currentWorkspace } = useWorkspace();
const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege();
const { assumedPrivilegeDetails } = useProjectPermission();
if (!assumedPrivilegeDetails) return null;
return (
<div className="z-10 -mx-4 flex items-center justify-center gap-2 rounded border border-mineshaft-600 bg-primary-400 p-2 text-mineshaft-800 shadow">
<div>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
You are currently viewing the project with privileges of{" "}
<b>
{assumedPrivilegeDetails?.actorType === ActorType.IDENTITY ? "identity" : "user"}{" "}
{assumedPrivilegeDetails?.actorName}
</b>
</div>
<div>
<Button
size="xs"
variant="outline_bg"
className="hover:bg-mineshaft-500"
onClick={() => {
exitAssumePrivilegeMode.mutate(
{
projectId: currentWorkspace.id
},
{
onSuccess: () => {
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`;
}
}
);
}}
>
Click to exit
</Button>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { AssumePrivilegeModeBanner } from "./AssumePrivilegeModeBanner";
+62
View File
@@ -11,3 +11,65 @@ export const formatReservedPaths = (secretPath: string) => {
export const camelCaseToSpaces = (input: string) => {
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
};
export const formatSessionUserAgent = (userAgent: string) => {
const result = {
os: "Unknown",
browser: "Unknown",
device: "Desktop"
};
// Operating System detection
if (userAgent.includes("Windows")) {
result.os = "Windows";
} else if (
userAgent.includes("Mac OS") ||
userAgent.includes("Macintosh") ||
userAgent.includes("macOS")
) {
result.os = "macOS";
} else if (userAgent.includes("Linux") && !userAgent.includes("Android")) {
result.os = "Linux";
} else if (userAgent.includes("Android")) {
result.os = "Android";
result.device = "Mobile";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
result.os = "iOS";
result.device = userAgent.includes("iPad") ? "Tablet" : "Mobile";
}
// Browser detection
if (userAgent.includes("Firefox/")) {
result.browser = "Firefox";
} else if (userAgent.includes("Edge/") || userAgent.includes("Edg/")) {
result.browser = "Edge";
} else if (userAgent.includes("Brave/") || userAgent.includes("Brave ")) {
result.browser = "Brave";
} else if (
userAgent.includes("Chrome/") &&
!userAgent.includes("Chromium/") &&
!userAgent.includes("Edg/")
) {
result.browser = "Chrome";
} else if (
userAgent.includes("Safari/") &&
!userAgent.includes("Chrome/") &&
!userAgent.includes("Chromium/")
) {
result.browser = "Safari";
} else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) {
result.browser = "Opera";
} else if (userAgent.includes("Trident/") || userAgent.includes("MSIE")) {
result.browser = "Internet Explorer";
}
if (userAgent.toLowerCase() === "cli") {
result.browser = "CLI";
}
return result;
};
@@ -1,7 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { AxiosError } from "axios";
import { addSeconds, formatISO } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { SessionStorageKeys } from "@app/const";
import { ROUTE_PATHS } from "@app/const/routes";
import { userKeys } from "@app/hooks/api";
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
@@ -24,6 +26,16 @@ export const Route = createFileRoute("/_authenticate")({
title: "Access Restricted",
text: " You need to log in to access this page. Please log in to continue."
});
// persist current URL in session storage so that we can come back to this after successful login
sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 60)),
data: window.location.href
})
);
throw redirect({
to: "/login"
});
@@ -18,8 +18,10 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { TeamCityConnectionForm } from "./TeamCityConnectionForm";
import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
import { VercelConnectionForm } from "./VercelConnectionForm";
import { WindmillConnectionForm } from "./WindmillConnectionForm";
@@ -89,6 +91,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <WindmillConnectionForm onSubmit={onSubmit} />;
case AppConnection.Auth0:
return <Auth0ConnectionForm onSubmit={onSubmit} />;
case AppConnection.LDAP:
return <LdapConnectionForm onSubmit={onSubmit} />;
case AppConnection.TeamCity:
return <TeamCityConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -153,6 +159,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <WindmillConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Auth0:
return <Auth0ConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.LDAP:
return <LdapConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.TeamCity:
return <TeamCityConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,329 @@
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 { DistinguishedNameRegex } from "@app/helpers/string";
import {
LdapConnectionMethod,
LdapConnectionProvider,
TLdapConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TLdapConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.LDAP)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(LdapConnectionMethod.SimpleBind),
credentials: z.object({
provider: z.nativeEnum(LdapConnectionProvider),
url: z
.string()
.regex(/^ldaps?:\/\//, 'Must start with "ldaps://" or "ldap://"')
.url()
.trim()
.min(1, "LDAP URL required"),
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
password: z.string().trim().min(1, "Password required"),
sslRejectUnauthorized: z.boolean(),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.LDAP,
method: LdapConnectionMethod.SimpleBind,
credentials: {
provider: LdapConnectionProvider.ActiveDirectory,
url: "",
dn: "",
password: "",
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty },
watch
} = form;
const selectedProvider = watch("credentials.provider");
const sslEnabled = watch("credentials.url")?.startsWith("ldaps://") ?? false;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(onSubmit)(e);
}}
>
{!isUpdate && <GenericAppConnectionsFields />}
<div className="grid grid-cols-2 items-center gap-2">
<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.LDAP].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(LdapConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.provider"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="LDAP Provider"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(LdapConnectionProvider).map((provider) => {
return (
<SelectItem value={provider} className="capitalize" key={provider}>
{provider.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
</div>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List
className={`-pb-1 ${selectedTabIndex === 1 ? "mb-3" : "mb-6"} w-full border-b-2 border-mineshaft-600`}
>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
Configuration
</Tab>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
SSL ({sslEnabled ? "Enabled" : "Disabled"})
</Tab>
</Tab.List>
{selectedTabIndex === 1 && (
<div className="mb-2 text-xs text-mineshaft-300">Requires ldaps:// URL</div>
)}
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
<Tab.Panel>
<Controller
name="credentials.url"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="LDAP URL"
>
<Input {...field} placeholder="ldap://domain-or-ip:389" />
</FormControl>
)}
/>
<div className="grid grid-cols-2 gap-2">
<Controller
name="credentials.dn"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Binding Distinguished Name (DN)"
>
<Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" />
</FormControl>
)}
/>
<Controller
name="credentials.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Binding Password"
>
<SecretInput
containerClassName="text-gray-400 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.sslCertificate"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={sslEnabled ? "" : "opacity-50"}
label="SSL Certificate"
isOptional
>
<TextArea
className="h-[3.6rem] !resize-none"
{...field}
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
<Controller
name="credentials.sslRejectUnauthorized"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className={sslEnabled ? "" : "opacity-50"}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-reject-unauthorized"
thumbClassName="bg-mineshaft-800"
isChecked={sslEnabled ? value : false}
onCheckedChange={onChange}
isDisabled={!sslEnabled}
>
<p className="w-[9.5rem]">
Reject Unauthorized
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will only connect to the server if it has a
valid, trusted SSL certificate.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
<div className="mt-8 flex items-center">
<Button
className="mr-4 capitalize"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : `Connect to ${selectedProvider.replace("-", " ")}`}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,150 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { TeamCityConnectionMethod, TTeamCityConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TTeamCityConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.TeamCity)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(TeamCityConnectionMethod.AccessToken),
credentials: z.object({
accessToken: z.string().trim().min(1, "Access Token required"),
instanceUrl: z.string().trim().url("Invalid Instance URL")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const TeamCityConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.TeamCity,
method: TeamCityConnectionMethod.AccessToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
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.TeamCity].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(TeamCityConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.instanceUrl"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Instance URL"
tooltipClassName="max-w-sm"
tooltipText="The URL at which your TeamCity instance is hosted."
>
<Input {...field} placeholder="https://yourcompany.teamcity.com" />
</FormControl>
)}
/>
<Controller
name="credentials.accessToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Token"
>
<SecretInput
containerClassName="text-gray-400 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 className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to TeamCity"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -125,7 +125,7 @@ export const AppConnectionRow = ({
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
@@ -68,7 +69,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props)
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member
);
@@ -37,6 +37,7 @@ import {
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -88,7 +89,8 @@ export const SpecificPrivilegeSecretForm = ({
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled =
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) &&
Boolean(privilege);
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const requestAccess = useCreateAccessRequest();
@@ -1,9 +1,12 @@
import { useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faChevronRight,
faClock,
faEllipsisV,
faFilter,
faMagnifyingGlass,
faSearch,
faTrash,
@@ -15,6 +18,14 @@ import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState,
HoverCard,
HoverCardContent,
@@ -40,7 +51,7 @@ import {
useWorkspace
} from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -65,13 +76,22 @@ enum MembersOrderBy {
Email = "email"
}
type Filter = {
roles: string[];
};
export const MembersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>({
roles: []
});
const filterRoles = useMemo(() => filter.roles, [filter.roles]);
const userId = user?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles } = useGetProjectRoles(workspaceId);
const {
search,
@@ -86,9 +106,20 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10)
});
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString());
};
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(
workspaceId,
undefined,
filterRoles
);
const filteredUsers = useMemo(
() =>
@@ -142,14 +173,81 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderDirection(OrderByDirection.ASC);
};
const isTableFiltered = Boolean(filter.roles.length);
const handleRoleToggle = useCallback(
(roleSlug: string) =>
setFilter((state) => {
const roles = state.roles || [];
if (roles.includes(roleSlug)) {
return { ...state, roles: roles.filter((role) => role !== roleSlug) };
}
return { ...state, roles: [...roles, roleSlug] };
}),
[]
);
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<div className="flex gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Users"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 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>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Roles
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Roles to Filter Users
</DropdownMenuLabel>
{projectRoles?.map(({ id, slug, name }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleRoleToggle(slug);
}}
key={id}
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
@@ -358,7 +456,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
@@ -6,13 +6,27 @@ import { formatRelative } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAssumeProjectPrivileges,
useDeleteIdentityFromWorkspace,
useGetWorkspaceIdentityMembershipDetails
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection";
import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection";
@@ -35,8 +49,28 @@ const Page = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteIdentity",
"upgradePlan"
"assumePrivileges"
] as const);
const assumePrivileges = useAssumeProjectPrivileges();
const handleAssumePrivileges = async () => {
assumePrivileges.mutate(
{
actorId: identityId,
actorType: ActorType.IDENTITY,
projectId: workspaceId
},
{
onSuccess: () => {
createNotification({
type: "success",
text: "Identity privilege assumption has started"
});
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`;
}
}
);
};
const onRemoveIdentitySubmit = async () => {
try {
@@ -100,6 +134,24 @@ const Page = () => {
>
Copy Membership ID
</Button>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
renderTooltip
allowedLabel="Assume privileges of the user"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("assumePrivileges")}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
@@ -137,6 +189,15 @@ const Page = () => {
deleteKey="remove"
onDeleteApproved={() => onRemoveIdentitySubmit()}
/>
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to assume privileges of this identity?"
subTitle="This will set your privileges to those of the identity for the next hour."
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
</>
) : (
<EmptyState title="Error: Unable to find the identity." className="py-12" />
@@ -32,7 +32,7 @@ import {
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -97,7 +97,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
});
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
@@ -22,7 +22,12 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
@@ -83,7 +88,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
identityId={identityId}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, {
identityId
})
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -66,7 +67,7 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => {
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionIdentityActions.Edit,
ProjectPermissionSub.Identity
);
@@ -6,15 +6,28 @@ import { formatRelative } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2";
import {
Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api";
import {
useAssumeProjectPrivileges,
useDeleteUserFromWorkspace,
useGetWorkspaceUserDetails
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
@@ -35,12 +48,34 @@ export const Page = () => {
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } =
useDeleteUserFromWorkspace();
const assumePrivileges = useAssumeProjectPrivileges();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeMember",
"upgradePlan"
"upgradePlan",
"assumePrivileges"
] as const);
const handleAssumePrivileges = async () => {
const { userId } = popUp?.assumePrivileges?.data as { userId: string };
assumePrivileges.mutate(
{
actorId: userId,
actorType: ActorType.USER,
projectId: workspaceId
},
{
onSuccess: () => {
createNotification({
type: "success",
text: "User privilege assumption has started"
});
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`;
}
}
);
};
const handleRemoveUser = async () => {
if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return;
@@ -91,7 +126,29 @@ export const Page = () => {
description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`}
>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Assume privileges of the user"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={assumePrivileges.isPending}
onClick={() =>
handlePopUpOpen("assumePrivileges", { userId: membershipDetails?.user?.id })
}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove from project"
@@ -128,6 +185,15 @@ export const Page = () => {
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to assume privileges of this user?"
subTitle="This will set your privileges to those of the user for the next hour."
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -23,6 +23,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useUser
@@ -90,7 +91,7 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member)
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
}
/>
</motion.div>
@@ -31,7 +31,7 @@ import {
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -95,7 +95,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member
);
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
@@ -69,7 +70,7 @@ export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) =
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member
);
@@ -1,8 +1,9 @@
import { cloneElement } from "react";
import { cloneElement, useState } from "react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import {
faChevronDown,
faChevronRight,
faGripVertical,
faInfoCircle,
faPlus,
faTrash
@@ -36,14 +37,44 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
isDisabled
}: Props<T>) => {
const { control, watch } = useFormContext<TFormSchema>();
const items = useFieldArray({
const { fields, remove, insert, move } = useFieldArray({
control,
name: `permissions.${subject}`
});
const [isOpen, setIsOpen] = useToggle();
// const [hideFullReadAccess, setHideFullReadAccess] = useState(false);
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
if (!items.fields.length) return <div />;
if (!fields.length) return <div />;
const handleDragStart = (_: React.DragEvent, index: number) => {
setDraggedItem(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverItem(index);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) {
setDraggedItem(null);
setDragOverItem(null);
return;
}
move(draggedItem, dragOverItem);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
return (
<div className="border border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md">
@@ -62,17 +93,17 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
<FontAwesomeIcon icon={isOpen ? faChevronDown : faChevronRight} />
</div>
<div className="flex-grow">{title}</div>
{items.fields.length > 1 && (
{fields.length > 1 && (
<div>
<Tag size="xs" className="px-2">
{items.fields.length} rules
{fields.length} rules
</Tag>
</div>
)}
</div>
{isOpen && (
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
{items.fields.map((el, rootIndex) => {
{fields.map((el, rootIndex) => {
let isFullReadAccessEnabled = false;
if (subject === ProjectPermissionSub.Secrets) {
@@ -82,47 +113,72 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
return (
<div
key={el.id}
className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md"
>
{isConditionalSubjects(subject) && (
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
<div className="w-1/4">Permission</div>
<div className="mr-4 w-1/4">
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
isDisabled={isDisabled}
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
</div>
<div>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
</Tooltip>
</div>
</div>
className={twMerge(
"relative bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md",
dragOverItem === rootIndex ? "border-2 border-blue-400" : "",
draggedItem === rootIndex ? "opacity-50" : ""
)}
onDragOver={(e) => handleDragOver(e, rootIndex)}
onDrop={handleDrop}
>
{!isDisabled && (
<Tooltip position="left" content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, rootIndex)}
onDragEnd={handleDragEnd}
className="absolute right-3 top-2 cursor-move rounded-md bg-mineshaft-700 p-2 text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
)}
<div className="mb-4 flex items-center">
{isConditionalSubjects(subject) && (
<div className="flex w-full items-center text-gray-300">
<div className="w-1/4">Permission</div>
<div className="mr-4 w-1/4">
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
isDisabled={isDisabled}
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
</div>
<div>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="text-gray-400"
/>
</Tooltip>
</div>
</div>
)}
</div>
<div className="flex text-gray-300">
<div className="w-1/4">Actions</div>
<div className="flex flex-grow flex-wrap justify-start gap-8">
@@ -179,7 +235,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
size="xs"
className="mt-2"
onClick={() => {
items.insert(rootIndex + 1, [
insert(rootIndex + 1, [
{ read: false, edit: false, create: false, delete: false } as any
]);
}}
@@ -194,7 +250,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
variant="outline_bg"
size="xs"
className="mt-2 hover:border-red"
onClick={() => items.remove(rootIndex)}
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove policy
@@ -92,7 +92,8 @@ const MemberPolicyActionSchema = z.object({
[ProjectPermissionMemberActions.Create]: z.boolean().optional(),
[ProjectPermissionMemberActions.Edit]: z.boolean().optional(),
[ProjectPermissionMemberActions.Delete]: z.boolean().optional(),
[ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional()
[ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional(),
[ProjectPermissionMemberActions.AssumePrivileges]: z.boolean().optional()
});
const IdentityPolicyActionSchema = z.object({
@@ -100,7 +101,8 @@ const IdentityPolicyActionSchema = z.object({
[ProjectPermissionIdentityActions.Create]: z.boolean().optional(),
[ProjectPermissionIdentityActions.Edit]: z.boolean().optional(),
[ProjectPermissionIdentityActions.Delete]: z.boolean().optional(),
[ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional()
[ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional(),
[ProjectPermissionIdentityActions.AssumePrivileges]: z.boolean().optional()
});
const GroupPolicyActionSchema = z.object({
@@ -577,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canEdit = action.includes(ProjectPermissionMemberActions.Edit);
const canDelete = action.includes(ProjectPermissionMemberActions.Delete);
const canGrantPrivileges = action.includes(ProjectPermissionMemberActions.GrantPrivileges);
const canAssumePrivileges = action.includes(ProjectPermissionMemberActions.AssumePrivileges);
if (!formVal[subject]) formVal[subject] = [{}];
@@ -587,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0][ProjectPermissionMemberActions.Delete] = true;
if (canGrantPrivileges)
formVal[subject]![0][ProjectPermissionMemberActions.GrantPrivileges] = true;
if (canAssumePrivileges)
formVal[subject]![0][ProjectPermissionMemberActions.AssumePrivileges] = true;
return;
}
@@ -596,6 +601,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canEdit = action.includes(ProjectPermissionIdentityActions.Edit);
const canDelete = action.includes(ProjectPermissionIdentityActions.Delete);
const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges);
const canAssumePrivileges = action.includes(
ProjectPermissionIdentityActions.AssumePrivileges
);
if (!formVal[subject]) formVal[subject] = [{ conditions: [] }];
@@ -606,6 +614,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true;
if (canGrantPrivileges)
formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true;
if (canAssumePrivileges)
formVal[subject]![0][ProjectPermissionIdentityActions.AssumePrivileges] = true;
return;
}
@@ -904,7 +914,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Add", value: ProjectPermissionMemberActions.Create },
{ label: "Modify", value: ProjectPermissionMemberActions.Edit },
{ label: "Remove", value: ProjectPermissionMemberActions.Delete },
{ label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges }
{ label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges },
{ label: "Assume Privileges", value: ProjectPermissionMemberActions.AssumePrivileges }
]
},
[ProjectPermissionSub.Identity]: {
@@ -914,7 +925,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Add", value: ProjectPermissionIdentityActions.Create },
{ label: "Modify", value: ProjectPermissionIdentityActions.Edit },
{ label: "Remove", value: ProjectPermissionIdentityActions.Delete },
{ label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges }
{ label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges },
{ label: "Assume Privileges", value: ProjectPermissionIdentityActions.AssumePrivileges }
]
},
[ProjectPermissionSub.Groups]: {
@@ -9,6 +9,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol";
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol";
import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol";
@@ -43,6 +44,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <VercelSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Windmill:
return <WindmillSyncDestinationCol secretSync={secretSync} />;
case SecretSync.TeamCity:
return <TeamCitySyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -0,0 +1,14 @@
import { TTeamCitySync } from "@app/hooks/api/secretSyncs/types/teamcity-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TTeamCitySync;
};
export const TeamCitySyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -94,6 +94,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.workspace;
secondaryText = destinationConfig.path;
break;
case SecretSync.TeamCity:
primaryText = destinationConfig.project;
secondaryText = destinationConfig.buildConfig;
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -135,7 +135,6 @@ export const SecretOverviewSecretRotationRow = ({
<img
src={`/images/integrations/${image}`}
style={{
height: "11px",
width: "11px"
}}
alt={`${rotationType} logo`}
@@ -25,7 +25,7 @@ import {
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
@@ -289,7 +289,10 @@ export const AccessApprovalRequest = ({
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
@@ -22,7 +22,7 @@ import {
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useUser,
@@ -171,7 +171,10 @@ export const SecretApprovalRequest = () => {
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
@@ -6,10 +6,28 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetWorkspaceUsers } from "@app/hooks/api";
const ReminderFormSchema = z.object({
note: z.string().optional().nullable(),
recipients: z
.array(
z.object({
label: z.string(),
value: z.string().uuid()
})
)
.optional(),
days: z
.number()
.min(1, { message: "Must be at least 1 day" })
@@ -22,6 +40,7 @@ interface ReminderFormProps {
isOpen: boolean;
repeatDays?: number | null;
note?: string | null;
recipients?: string[] | null;
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
}
@@ -29,12 +48,16 @@ export const CreateReminderForm = ({
isOpen,
onOpenChange,
repeatDays,
note
note,
recipients
}: ReminderFormProps) => {
const { currentWorkspace } = useWorkspace();
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
const {
register,
control,
reset,
setValue,
handleSubmit,
formState: { isSubmitting }
@@ -51,13 +74,23 @@ export const CreateReminderForm = ({
};
useEffect(() => {
if (isOpen) {
reset({
days: repeatDays || undefined,
note: note || ""
});
// On initial load, filter the members to only include the recipients
if (members.length) {
const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id));
setValue(
"recipients",
filteredMembers.map((m) => ({
label: m.user.username || m.user.email,
value: m.user.id
}))
);
}
}, [isOpen]);
}, [members, isOpen, recipients]);
useEffect(() => {
if (repeatDays) setValue("days", repeatDays);
if (note) setValue("note", note);
}, [repeatDays, note]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -74,6 +107,7 @@ export const CreateReminderForm = ({
render={({ field, fieldState }) => (
<>
<FormControl
isRequired
className="mb-0"
label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)}
@@ -83,6 +117,7 @@ export const CreateReminderForm = ({
onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
type="number"
placeholder="31"
defaultValue={repeatDays || undefined}
value={field.value || undefined}
/>
</FormControl>
@@ -105,11 +140,43 @@ export const CreateReminderForm = ({
placeholder="Remember to rotate the AWS secret every month."
className="border border-mineshaft-600 text-sm"
rows={8}
defaultValue={note || ""}
reSize="none"
cols={30}
{...register("note")}
/>
</FormControl>
<Controller
control={control}
name="recipients"
render={({ field }) => (
<FormControl
tooltipText={
<div>
Select users to receive reminders.
<br />
<br /> If none are selected, all project members will receive the reminder.
</div>
}
label="Recipients"
className="mb-0"
>
<FilterableSelect
className="w-full"
placeholder="Select reminder recipients..."
isMulti
name="recipients"
options={members.map((member) => ({
label: member.user.username || member.user.email,
value: member.user.id
}))}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-4">
<Button

Some files were not shown because too many files have changed in this diff Show More