Merge branch 'main' into feat/point-in-time-revamp

This commit is contained in:
carlosmonastyrski
2025-05-26 14:42:55 -03:00
258 changed files with 4999 additions and 1499 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

@@ -32,7 +32,7 @@ const formSchema = z.object({
environments: z
.object({
name: z.string().trim().min(1),
slug: slugSchema({ min: 1, max: 32 })
slug: slugSchema({ min: 1, max: 64 })
})
.array()
.nullish()
@@ -21,7 +21,7 @@ import {
import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().max(500).optional()
});
@@ -18,9 +18,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Distinguished Name (DN)">
{activeCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay label="DN/UPN">{activeCredentials?.dn}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{activeCredentials?.password}
</CredentialDisplay>
@@ -28,9 +26,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
}
inactiveCredentials={
<>
<CredentialDisplay label="Distinguished Name (DN)">
{inactiveCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay label="DN/UPN">{inactiveCredentials?.dn}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password}
</CredentialDisplay>
@@ -48,7 +48,8 @@ const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Fo
"rotateAtUtc"
]
},
{ name: "Parameters", key: "parameters", fields: ["parameters"] },
// @ts-expect-error temporary parameters aren't present on all forms
{ name: "Parameters", key: "parameters", fields: ["parameters", "temporaryParameters"] },
{ name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
@@ -75,7 +76,7 @@ export const SecretRotationV2Form = ({
const { rotationOption } = useSecretRotationV2Option(type);
const formMethods = useForm<TSecretRotationV2Form>({
resolver: zodResolver(SecretRotationV2FormSchema),
resolver: zodResolver(SecretRotationV2FormSchema(Boolean(secretRotation))),
defaultValues: secretRotation
? {
...secretRotation,
@@ -2,40 +2,135 @@ 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 { FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
export const LdapPasswordRotationParametersFields = () => {
const { control } = useFormContext<
const { control, watch, setValue } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.LdapPassword;
}
>();
const [id, rotationMethod] = watch(["id", "parameters.rotationMethod"]);
const isUpdate = Boolean(id);
return (
<>
<Controller
name="parameters.dn"
name="parameters.rotationMethod"
control={control}
defaultValue={LdapPasswordRotationMethod.ConnectionPrincipal}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
tooltipText={
<>
<span>Determines how the rotation will be performed:</span>
<ul className="ml-4 mt-2 flex list-disc flex-col gap-2">
<li>
<span className="font-medium">Connection Principal</span> - The Connection
principal will rotate the target principal&#39;s password.
</li>
<li>
<span className="font-medium">Target Principal</span> - The target principal
will rotate their own password.
</li>
</ul>
</>
}
tooltipClassName="max-w-sm"
errorText={error?.message}
label="Distinguished Name (DN)"
isError={Boolean(error?.message)}
label="Rotation Method"
helperText={
// eslint-disable-next-line no-nested-ternary
isUpdate
? "Cannot be updated."
: value === LdapPasswordRotationMethod.ConnectionPrincipal
? "The connection principal will rotate the target principal's password"
: "The target principal will rotate their own password"
}
>
<Input
<Select
isDisabled={isUpdate}
value={value}
onChange={onChange}
placeholder="CN=John,OU=Users,DC=example,DC=com"
/>
onValueChange={(val) => {
setValue(
"temporaryParameters",
val === LdapPasswordRotationMethod.TargetPrincipal
? {
password: ""
}
: undefined
);
onChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(LdapPasswordRotationMethod).map((method) => {
return (
<SelectItem value={method} className="capitalize" key={method}>
{method.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="flex gap-3">
<Controller
name="parameters.dn"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="flex-1"
isError={Boolean(error)}
errorText={error?.message}
label="Target Principal's DN/UPN"
tooltipText="The DN/UPN of the principal that you want to perform password rotation on."
tooltipClassName="max-w-sm"
helperText={isUpdate ? "Cannot be updated." : undefined}
>
<Input
isDisabled={isUpdate}
value={value}
onChange={onChange}
placeholder="CN=John,OU=Users,DC=example,DC=com"
/>
</FormControl>
)}
/>
{rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && !isUpdate && (
<Controller
name="temporaryParameters.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="flex-1"
isError={Boolean(error)}
errorText={error?.message}
label="Target Principal's Password"
>
<Input
value={value}
onChange={onChange}
type="password"
placeholder="****************"
/>
</FormControl>
)}
/>
)}
</div>
<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">
<div className="grid grid-cols-2 gap-x-3 gap-y-1 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 pt-3">
<Controller
control={control}
name="parameters.passwordRequirements.length"
@@ -45,7 +140,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
helperText="The length of the password to generate"
tooltipText="The length of the password to generate"
>
<Input
type="number"
@@ -67,7 +162,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
tooltipText="Minimum number of digits"
>
<Input
type="number"
@@ -88,7 +183,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Lowercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase characters"
tooltipText="Minimum number of lowercase characters"
>
<Input
type="number"
@@ -109,7 +204,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Uppercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase characters"
tooltipText="Minimum number of uppercase characters"
>
<Input
type="number"
@@ -130,7 +225,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
tooltipText="Minimum number of symbols"
>
<Input
type="number"
@@ -151,7 +246,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Allowed Symbols"
isError={Boolean(error)}
errorText={error?.message}
helperText="Symbols to use in generated password"
tooltipText="Symbols to use in generated password"
>
<Input
placeholder="-_.~!*"
@@ -15,13 +15,35 @@ export const LdapPasswordRotationReviewFields = () => {
const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]);
const { passwordRequirements } = parameters;
return (
<>
<SecretRotationReviewSection label="Parameters">
<GenericFieldLabel label="Distinguished Name (DN)">{parameters.dn}</GenericFieldLabel>
<GenericFieldLabel label="DN/UPN">{parameters.dn}</GenericFieldLabel>
</SecretRotationReviewSection>
{passwordRequirements && (
<SecretRotationReviewSection label="Password Requirements">
<GenericFieldLabel label="Length">{passwordRequirements.length}</GenericFieldLabel>
<GenericFieldLabel label="Minimum Digits">
{passwordRequirements.required.digits}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Lowercase Characters">
{passwordRequirements.required.lowercase}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Uppercase Characters">
{passwordRequirements.required.uppercase}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Symbols">
{passwordRequirements.required.symbols}
</GenericFieldLabel>
<GenericFieldLabel label="Allowed Symbols">
{passwordRequirements.allowedSymbols}
</GenericFieldLabel>
</SecretRotationReviewSection>
)}
<SecretRotationReviewSection label="Secrets Mapping">
<GenericFieldLabel label="Distinguished Name (DN)">{dn}</GenericFieldLabel>
<GenericFieldLabel label="DN/UPN">{dn}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</SecretRotationReviewSection>
</>
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
type Props = {
label: "Parameters" | "Secrets Mapping";
label: "Parameters" | "Secrets Mapping" | "Password Requirements";
children: ReactNode;
};
@@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => {
const items = [
{
name: "DN",
name: "DN/UPN",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
@@ -6,16 +6,36 @@ import { AzureClientSecretRotationSchema } from "@app/components/secret-rotation
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";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
const SecretRotationUnionSchema = z.discriminatedUnion("type", [
Auth0ClientSecretRotationSchema,
AzureClientSecretRotationSchema,
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema
]);
export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
z
.intersection(
z.discriminatedUnion("type", [
Auth0ClientSecretRotationSchema,
AzureClientSecretRotationSchema,
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema
]),
z.object({ id: z.string().optional() })
)
.superRefine((val, ctx) => {
if (val.type !== SecretRotation.LdapPassword || isUpdate) return;
export const SecretRotationV2FormSchema = SecretRotationUnionSchema;
// this has to go on union or breaks discrimination
if (
val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal &&
!val.temporaryParameters?.password
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password required",
path: ["temporaryParameters", "password"]
});
}
});
export type TSecretRotationV2Form = z.infer<typeof SecretRotationV2FormSchema>;
export type TSecretRotationV2Form = z.infer<ReturnType<typeof SecretRotationV2FormSchema>>;
@@ -2,8 +2,9 @@ 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 { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
export const LdapPasswordRotationSchema = z
.object({
@@ -12,13 +13,24 @@ export const LdapPasswordRotationSchema = z
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
passwordRequirements: PasswordRequirementsSchema.optional()
.min(1, "DN/UPN required")
.refine(
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
passwordRequirements: PasswordRequirementsSchema.optional(),
rotationMethod: z.nativeEnum(LdapPasswordRotationMethod).optional()
}),
secretsMapping: z.object({
dn: z.string().trim().min(1, "Distinguished Name (DN) required"),
dn: z.string().trim().min(1, "DN/UPN required"),
password: z.string().trim().min(1, "Password required")
})
}),
temporaryParameters: z
.object({
password: z.string().min(1, "Password required")
})
.optional()
})
.merge(BaseSecretRotationSchema);
@@ -1,5 +1,7 @@
import { z } from "zod";
export type TPasswordRequirements = z.infer<typeof PasswordRequirementsSchema>;
export const PasswordRequirementsSchema = z
.object({
length: z
@@ -2,16 +2,23 @@ import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { usePopUp } from "@app/hooks";
import { SecretSync, useSecretSyncOptions } from "@app/hooks/api/secretSyncs";
import { UpgradePlanModal } from "../license/UpgradePlanModal";
type Props = {
onSelect: (destination: SecretSync) => void;
};
export const SecretSyncSelect = ({ onSelect }: Props) => {
const { subscription } = useSubscription();
const { isPending, data: secretSyncOptions } = useSecretSyncOptions();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
@@ -23,14 +30,17 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
return (
<div className="grid grid-cols-4 gap-2">
{secretSyncOptions?.map(({ destination }) => {
{secretSyncOptions?.map(({ destination, enterprise }) => {
const { image, name } = SECRET_SYNC_MAP[destination];
return (
<button
type="button"
key={destination}
onClick={() => onSelect(destination)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
onClick={() =>
enterprise && !subscription.enterpriseSecretSyncs
? handlePopUpOpen("upgradePlan")
: onSelect(destination)
}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
@@ -45,6 +55,11 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
</button>
);
})}
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use every Secret Sync if you switch to Infisical's Enterprise plan."
/>
<Tooltip
side="bottom"
className="max-w-sm py-4"
@@ -0,0 +1,74 @@
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 {
TOnePassVault,
useOnePassConnectionListVaults
} from "@app/hooks/api/appConnections/1password";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const OnePassSyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.OnePass }
>();
const connectionId = useWatch({ name: "connection.id", control });
const { data: vaults, isLoading: isVaultsLoading } = useOnePassConnectionListVaults(
connectionId,
{
enabled: Boolean(connectionId)
}
);
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.vaultId", "");
}}
/>
<Controller
name="destinationConfig.vaultId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Vault"
helperText={
<Tooltip
className="max-w-md"
content="Ensure the vault exists in the connection's OnePass instance URL."
>
<div>
<span>Don&#39;t see the vault you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isVaultsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={vaults?.find((v) => v.id === value) ?? null}
onChange={(option) => onChange((option as SingleValue<TOnePassVault>)?.id ?? null)}
options={vaults}
placeholder="Select a vault..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
</>
);
};
@@ -3,6 +3,7 @@ import { useFormContext } from "react-hook-form";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
import { OnePassSyncFields } from "./1PasswordSyncFields";
import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields";
import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields";
import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields";
@@ -55,6 +56,8 @@ export const SecretSyncDestinationFields = () => {
return <TeamCitySyncFields />;
case SecretSync.OCIVault:
return <OCIVaultSyncFields />;
case SecretSync.OnePass:
return <OnePassSyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
@@ -49,6 +49,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.Windmill:
case SecretSync.HCVault:
case SecretSync.TeamCity:
case SecretSync.OnePass:
case SecretSync.OCIVault:
AdditionalSyncOptionsFieldsComponent = null;
break;
@@ -0,0 +1,12 @@
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 OnePassSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.OnePass }>();
const vaultId = watch("destinationConfig.vaultId");
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
};
@@ -24,6 +24,7 @@ import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields";
import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields";
import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields";
import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields";
import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields";
import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields";
import { VercelSyncReviewFields } from "./VercelSyncReviewFields";
@@ -96,6 +97,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.OCIVault:
DestinationFieldsComponent = <OCIVaultSyncReviewFields />;
break;
case SecretSync.OnePass:
DestinationFieldsComponent = <OnePassSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
@@ -0,0 +1,13 @@
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 OnePassSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.OnePass),
destinationConfig: z.object({
vaultId: z.string().trim().min(1, "Vault ID required")
})
})
);
@@ -1,5 +1,6 @@
import { z } from "zod";
import { OnePassSyncDestinationSchema } from "./1password-sync-destination-schema";
import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema";
import { AwsSecretsManagerSyncDestinationSchema } from "./aws-secrets-manager-sync-destination-schema";
import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema";
@@ -31,7 +32,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
WindmillSyncDestinationSchema,
HCVaultSyncDestinationSchema,
TeamCitySyncDestinationSchema,
OCIVaultSyncDestinationSchema
OCIVaultSyncDestinationSchema,
OnePassSyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema;
@@ -51,6 +51,7 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
isVisible?: boolean;
isReadOnly?: boolean;
isDisabled?: boolean;
canEditButNotView?: boolean;
secretPath?: string;
environment?: string;
containerClassName?: string;
@@ -70,6 +71,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
containerClassName,
secretPath: propSecretPath,
environment: propEnvironment,
canEditButNotView,
...props
},
ref
@@ -273,6 +275,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
<Popover.Trigger asChild>
<SecretInput
{...props}
canEditButNotView={canEditButNotView}
ref={handleRef}
onKeyDown={handleKeyDown}
value={value}
@@ -3,6 +3,7 @@ import { forwardRef, TextareaHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { useToggle } from "@app/hooks";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const replaceContentWithDot = (str: string) => {
@@ -51,6 +52,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
isReadOnly?: boolean;
isDisabled?: boolean;
containerClassName?: string;
canEditButNotView?: boolean;
};
const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all";
@@ -66,6 +68,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isDisabled,
isReadOnly,
onFocus,
canEditButNotView,
...props
},
ref
@@ -93,7 +96,15 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
onFocus={(evt) => {
onFocus?.(evt);
setIsSecretFocused.on();
evt.currentTarget.select();
if (canEditButNotView && value === HIDDEN_SECRET_VALUE) {
evt.currentTarget.select();
}
}}
onMouseDown={(e) => {
if (canEditButNotView && value === HIDDEN_SECRET_VALUE) {
e.preventDefault();
e.currentTarget.select();
}
}}
disabled={isDisabled}
spellCheck={false}
@@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext";
export type { ProjectPermissionSet, TProjectPermission } from "./types";
export {
ProjectPermissionActions,
ProjectPermissionApprovalActions,
ProjectPermissionCertificateActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
@@ -24,6 +24,15 @@ export enum ProjectPermissionSecretActions {
Delete = "delete"
}
export enum ProjectPermissionApprovalActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
AllowChangeBypass = "allow-change-bypass",
AllowAccessBypass = "allow-access-bypass"
}
export enum ProjectPermissionDynamicSecretActions {
ReadRootCredential = "read-root-credential",
CreateRootCredential = "create-root-credential",
@@ -291,7 +300,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval]
| [
ProjectPermissionIdentityActions,
(
+1
View File
@@ -10,6 +10,7 @@ export {
export type { TProjectPermission } from "./ProjectPermissionContext";
export {
ProjectPermissionActions,
ProjectPermissionApprovalActions,
ProjectPermissionCertificateActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
+5 -2
View File
@@ -23,6 +23,7 @@ import {
HumanitecConnectionMethod,
LdapConnectionMethod,
MsSqlConnectionMethod,
OnePassConnectionMethod,
PostgresConnectionMethod,
TAppConnection,
TeamCityConnectionMethod,
@@ -34,7 +35,7 @@ import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-con
export const APP_CONNECTION_MAP: Record<
AppConnection,
{ name: string; image: string; size?: number }
{ name: string; image: string; size?: number; enterprise?: boolean }
> = {
[AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" },
[AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" },
@@ -63,7 +64,8 @@ export const APP_CONNECTION_MAP: Record<
[AppConnection.HCVault]: { name: "Hashicorp Vault", image: "Vault.png", size: 65 },
[AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 },
[AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" },
[AppConnection.OCI]: { name: "OCI", image: "Oracle.png" }
[AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true },
[AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -89,6 +91,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case HumanitecConnectionMethod.ApiToken:
case TerraformCloudConnectionMethod.ApiToken:
case VercelConnectionMethod.ApiToken:
case OnePassConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
+6 -1
View File
@@ -51,6 +51,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
[SecretSync.OCIVault]: {
name: "OCI Vault",
image: "Oracle.png"
},
[SecretSync.OnePass]: {
name: "1Password",
image: "1Password.png"
}
};
@@ -69,7 +73,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Windmill]: AppConnection.Windmill,
[SecretSync.HCVault]: AppConnection.HCVault,
[SecretSync.TeamCity]: AppConnection.TeamCity,
[SecretSync.OCIVault]: AppConnection.OCI
[SecretSync.OCIVault]: AppConnection.OCI,
[SecretSync.OnePass]: AppConnection.OnePass
};
export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record<
+2
View File
@@ -15,3 +15,5 @@ export const isValidPath = (val: string): boolean => {
export const DistinguishedNameRegex =
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/;
export const UserPrincipalNameRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
@@ -0,0 +1,73 @@
const TABLE_PREFERENCES_KEY = "userTablePreferences";
export enum PreferenceKey {
PerPage = "perPage"
}
interface TableSpecificPreferences {
[preferenceKey: string]: any;
}
interface UserTablePreferences {
[tableName: string]: TableSpecificPreferences;
}
// Retrieves all table preferences from localStorage
const getAllTablePreferences = (): UserTablePreferences => {
try {
const preferencesString = localStorage.getItem(TABLE_PREFERENCES_KEY);
if (preferencesString) {
return JSON.parse(preferencesString) as UserTablePreferences;
}
} catch (error) {
console.error("Error reading user table preferences from localStorage:", error);
}
return {};
};
// Saves all table preferences to localStorage
const saveAllTablePreferences = (preferences: UserTablePreferences): void => {
try {
localStorage.setItem(TABLE_PREFERENCES_KEY, JSON.stringify(preferences));
} catch (error) {
console.error("Error saving user table preferences to localStorage:", error);
}
};
// Retrieves a specific preference for a given table
export const getUserTablePreference = <T>(
tableName: string,
preferenceKey: PreferenceKey,
defaultValue: T
): T => {
const preferences = getAllTablePreferences();
if (
preferences &&
typeof preferences === "object" &&
tableName in preferences &&
preferenceKey in preferences[tableName]
) {
const value = preferences[tableName][preferenceKey];
if (value !== undefined && value !== null) {
return value as T;
}
}
return defaultValue;
};
// Sets a specific preference for a given table and saves it to localStorage
export const setUserTablePreference = (
tableName: string,
preferenceKey: PreferenceKey,
value: any
): void => {
const preferences = getAllTablePreferences();
if (!preferences[tableName]) {
preferences[tableName] = {};
}
preferences[tableName][preferenceKey] = value;
saveAllTablePreferences(preferences);
};
@@ -131,20 +131,27 @@ export const useReviewAccessRequest = () => {
projectSlug: string;
envSlug?: string;
requestedBy?: string;
bypassReason?: string;
}
>({
mutationFn: async ({ requestId, status }) => {
mutationFn: async ({ requestId, status, bypassReason }) => {
const { data } = await apiRequest.post(
`/api/v1/access-approvals/requests/${requestId}/review`,
{
status
status,
bypassReason
}
);
return data;
},
onSuccess: (_, { projectSlug, envSlug, requestedBy }) => {
onSuccess: (_, { projectSlug, envSlug, requestedBy, bypassReason }) => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy)
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envSlug,
requestedBy,
bypassReason
)
});
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)
@@ -19,8 +19,12 @@ export const accessApprovalKeys = {
getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) =>
[{ workspaceId, environment }, "access-approval-policy"] as const,
getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) =>
[{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const,
getAccessApprovalRequests: (
projectSlug: string,
envSlug?: string,
requestedBy?: string,
bypassReason?: string
) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const,
getAccessApprovalRequestCount: (projectSlug: string) =>
[{ projectSlug }, "access-approval-request-count"] as const
};
@@ -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 { TOnePassVault } from "./types";
const onePassConnectionKeys = {
all: [...appConnectionKeys.all, "1password"] as const,
listVaults: (connectionId: string) =>
[...onePassConnectionKeys.all, "vaults", connectionId] as const
};
export const useOnePassConnectionListVaults = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TOnePassVault[],
unknown,
TOnePassVault[],
ReturnType<typeof onePassConnectionKeys.listVaults>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: onePassConnectionKeys.listVaults(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TOnePassVault[]>(
`/api/v1/app-connections/1password/${connectionId}/vaults`
);
return data;
},
...options
});
};
@@ -0,0 +1,12 @@
export type TOnePassVault = {
id: string;
name: string;
type: string;
items: number;
attributeVersion: number;
contentVersion: number;
createdAt: string;
updatedAt: string;
};
@@ -17,5 +17,6 @@ export enum AppConnection {
HCVault = "hashicorp-vault",
LDAP = "ldap",
TeamCity = "teamcity",
OCI = "oci"
OCI = "oci",
OnePass = "1password"
}
@@ -0,0 +1,14 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum OnePassConnectionMethod {
ApiToken = "api-token"
}
export type TOnePassConnection = TRootAppConnection & { app: AppConnection.OnePass } & {
method: OnePassConnectionMethod.ApiToken;
credentials: {
apiToken: string;
instanceUrl: string;
};
};
@@ -88,6 +88,10 @@ export type TOCIConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.OCI;
};
export type TOnePassConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.OnePass;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -106,7 +110,8 @@ export type TAppConnectionOption =
| TAuth0ConnectionOption
| THCVaultConnectionOption
| TTeamCityConnectionOption
| TOCIConnectionOption;
| TOCIConnectionOption
| TOnePassConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -128,4 +133,5 @@ export type TAppConnectionOptionMap = {
[AppConnection.LDAP]: TLdapConnectionOption;
[AppConnection.TeamCity]: TTeamCityConnectionOption;
[AppConnection.OCI]: TOCIConnectionOption;
[AppConnection.OnePass]: TOnePassConnectionOption;
};
@@ -1,4 +1,5 @@
import { AppConnection } from "../enums";
import { TOnePassConnection } from "./1password-connection";
import { TAppConnectionOption } from "./app-options";
import { TAuth0Connection } from "./auth0-connection";
import { TAwsConnection } from "./aws-connection";
@@ -20,6 +21,7 @@ import { TTerraformCloudConnection } from "./terraform-cloud-connection";
import { TVercelConnection } from "./vercel-connection";
import { TWindmillConnection } from "./windmill-connection";
export * from "./1password-connection";
export * from "./auth0-connection";
export * from "./aws-connection";
export * from "./azure-app-configuration-connection";
@@ -59,7 +61,8 @@ export type TAppConnection =
| THCVaultConnection
| TLdapConnection
| TTeamCityConnection
| TOCIConnection;
| TOCIConnection
| TOnePassConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -106,4 +109,5 @@ export type TAppConnectionMap = {
[AppConnection.LDAP]: TLdapConnection;
[AppConnection.TeamCity]: TTeamCityConnection;
[AppConnection.OCI]: TOCIConnection;
[AppConnection.OnePass]: TOnePassConnection;
};
@@ -123,7 +123,6 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.CREATE_PROJECT_TEMPLATE]: "Create project template",
[EventType.UPDATE_PROJECT_TEMPLATE]: "Update project template",
[EventType.DELETE_PROJECT_TEMPLATE]: "Delete project template",
[EventType.APPLY_PROJECT_TEMPLATE]: "Apply project template",
[EventType.GET_APP_CONNECTIONS]: "List App Connections",
[EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS]: "List App Connections Details",
[EventType.GET_APP_CONNECTION]: "Get App Connection",
@@ -189,7 +188,13 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.ADD_IDENTITY_LDAP_AUTH]: "Attached LDAP Auth to identity",
[EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity",
[EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved LDAP Auth for identity",
[EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity"
[EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity",
[EventType.UPDATE_ORG]: "Update Organization",
[EventType.CREATE_PROJECT]: "Create Project",
[EventType.UPDATE_PROJECT]: "Update Project",
[EventType.DELETE_PROJECT]: "Delete Project"
};
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
+7 -2
View File
@@ -131,7 +131,6 @@ export enum EventType {
CREATE_PROJECT_TEMPLATE = "create-project-template",
UPDATE_PROJECT_TEMPLATE = "update-project-template",
DELETE_PROJECT_TEMPLATE = "delete-project-template",
APPLY_PROJECT_TEMPLATE = "apply-project-template",
GET_APP_CONNECTIONS = "get-app-connections",
GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details",
GET_APP_CONNECTION = "get-app-connection",
@@ -183,5 +182,11 @@ export enum EventType {
MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS = "microsoft-teams-workflow-integration-check-installation-status",
MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS = "microsoft-teams-workflow-integration-get-teams",
MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET = "microsoft-teams-workflow-integration-get",
MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list"
MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list",
UPDATE_ORG = "update-org",
CREATE_PROJECT = "create-project",
UPDATE_PROJECT = "update-project",
DELETE_PROJECT = "delete-project"
}
@@ -1,3 +1,4 @@
import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
@@ -5,10 +6,17 @@ import {
TSecretRotationV2GeneratedCredentialsResponseBase
} from "@app/hooks/api/secretRotationsV2/types/shared";
export enum LdapPasswordRotationMethod {
ConnectionPrincipal = "connection-principal",
TargetPrincipal = "target-principal"
}
export type TLdapPasswordRotation = TSecretRotationV2Base & {
type: SecretRotation.LdapPassword;
parameters: {
dn: string;
rotationMethod?: LdapPasswordRotationMethod;
passwordRequirements?: TPasswordRequirements;
};
secretsMapping: {
dn: string;
+2 -1
View File
@@ -13,7 +13,8 @@ export enum SecretSync {
Windmill = "windmill",
HCVault = "hashicorp-vault",
TeamCity = "teamcity",
OCIVault = "oci-vault"
OCIVault = "oci-vault",
OnePass = "1password"
}
export enum SecretSyncStatus {
@@ -0,0 +1,15 @@
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 TOnePassSync = TRootSecretSync & {
destination: SecretSync.OnePass;
destinationConfig: {
vaultId: string;
};
connection: {
app: AppConnection.OnePass;
name: string;
id: string;
};
};
@@ -1,6 +1,7 @@
import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs";
import { DiscriminativePick } from "@app/types";
import { TOnePassSync } from "./1password-sync";
import { TAwsParameterStoreSync } from "./aws-parameter-store-sync";
import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync";
import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync";
@@ -21,6 +22,7 @@ export type TSecretSyncOption = {
name: string;
destination: SecretSync;
canImportSecrets: boolean;
enterprise?: boolean;
};
export type TSecretSync =
@@ -38,7 +40,8 @@ export type TSecretSync =
| TWindmillSync
| THCVaultSync
| TTeamCitySync
| TOCIVaultSync;
| TOCIVaultSync
| TOnePassSync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };
@@ -50,4 +50,6 @@ export type SubscriptionPlan = {
enforceMfa: boolean;
projectTemplates: boolean;
kmip: boolean;
enterpriseSecretSyncs: boolean;
enterpriseAppConnections: boolean;
};
+2
View File
@@ -1,6 +1,7 @@
export {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useRemoveMyDuplicateAccounts,
useRevokeMySessionById,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode
@@ -14,6 +15,7 @@ export {
useDeleteOrgMembership,
useGetMyAPIKeys,
useGetMyAPIKeysV2,
useGetMyDuplicateAccount,
useGetMyIp,
useGetMyOrganizationProjects,
useGetMySessions,
@@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => {
}
});
};
export const useRemoveMyDuplicateAccounts = () => {
return useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts");
return data;
}
});
};
+27
View File
@@ -37,6 +37,33 @@ export const useGetUser = () =>
queryFn: fetchUserDetails
});
export const fetchUserDuplicateAccounts = async () => {
const { data } = await apiRequest.get<{
users: Array<
User & {
isMyAccount: boolean;
organizations: { name: string; slug: string }[];
devices: {
ip: string;
userAgent: string;
}[];
}
>;
}>("/api/v1/user/duplicate-accounts");
return data.users;
};
export const useGetMyDuplicateAccount = () =>
useQuery({
queryKey: userKeys.getMyDuplicateAccount,
staleTime: 60 * 1000, // 1 min in ms
queryFn: fetchUserDuplicateAccounts,
select: (users) => ({
duplicateAccounts: users.filter((el) => !el.isMyAccount),
myAccount: users?.find((el) => el.isMyAccount)
})
});
export const useDeleteMe = () => {
const queryClient = useQueryClient();
@@ -1,5 +1,6 @@
export const userKeys = {
getUser: ["user"] as const,
getMyDuplicateAccount: ["user-duplicate-account"] as const,
getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
+1 -1
View File
@@ -7,7 +7,7 @@ interface SlugSchemaInputs {
field?: string;
}
export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => {
export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => {
return z
.string()
.trim()
@@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries";
import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation";
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
@@ -40,6 +41,7 @@ export const PasswordStep = ({
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true);
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
@@ -109,6 +111,13 @@ export const PasswordStep = ({
return;
}
const userDuplicateAccount = await fetchUserDuplicateAccounts();
const hasDuplicate = userDuplicateAccount?.length > 1;
if (hasDuplicate) {
setRemoveDuplicateLater(false);
return;
}
await navigateUserToOrg(navigate, organizationId);
};
@@ -306,6 +315,18 @@ export const PasswordStep = ({
);
}
if (!removeDuplicateLater) {
return (
<EmailDuplicationConfirmation
onRemoveDuplicateLater={() =>
navigateUserToOrg(navigate, organizationId).catch(() =>
createNotification({ text: "Failed to navigate user", type: "error" })
)
}
/>
);
}
if (hasExchangedPrivateKey) {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
@@ -0,0 +1,164 @@
import { useCallback } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal, Tooltip } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import {
useGetMyDuplicateAccount,
useLogoutUser,
useRemoveMyDuplicateAccounts
} from "@app/hooks/api";
type Props = {
onRemoveDuplicateLater: () => void;
};
export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) => {
const duplicateAccounts = useGetMyDuplicateAccount();
const removeDuplicateEmails = useRemoveMyDuplicateAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const logout = useLogoutUser(true);
const { popUp, handlePopUpToggle } = usePopUp(["removeDuplicateConfirm"] as const);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
<div className="mx-auto mt-20 w-fit max-w-2xl rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-6">
<h1 className="mb-2 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Multiple Accounts Detected
</h1>
<p className="text-md mb-4 text-center text-white">
<span className="text-slate-300">You&apos;re currently logged in as</span>{" "}
<b>{duplicateAccounts?.data?.myAccount?.username}</b>.
</p>
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<p className="mb-2 mt-1 text-sm text-bunker-300">
We&apos;ve detected multiple accounts using variations of the same email address.
</p>
</div>
</div>
<div className="mb-4 w-full border-b border-mineshaft-400 pb-1 text-sm text-mineshaft-200">
Your other accounts
</div>
<div className="thin-scrollbar flex h-full max-h-60 w-full flex-col items-stretch gap-2 overflow-auto rounded-md">
{duplicateAccounts?.data?.duplicateAccounts?.map((el) => {
const lastSession = el.devices?.at(-1);
return (
<div
key={el.id}
className="flex items-center gap-8 rounded-md bg-mineshaft-700 px-4 py-3 text-gray-200"
>
<div className="group flex flex-grow flex-col">
<div className="truncate text-sm transition-colors">{el.username}</div>
<div className="mt-2 text-xs">
Last logged in at {format(new Date(el.updatedAt), "Pp")}
</div>
<div className="mt-2 text-xs">
Organizations: {el?.organizations?.map((i) => i.slug)?.join(",")}
</div>
</div>
<div>
<Tooltip
className="max-w-lg"
content={
<div className="flex flex-col space-y-1 text-sm">
<div>IP: {lastSession?.ip || "-"}</div>
<div>User Agent: {lastSession?.userAgent || "-"}</div>
</div>
}
>
<FontAwesomeIcon icon={faInfoCircle} />
</Tooltip>
</div>
</div>
);
})}
</div>
<div className="mt-4 flex w-full flex-col">
<div className="flex gap-6">
<Button
className="flex-1 flex-grow"
isLoading={removeDuplicateEmails.isPending}
onClick={() => handlePopUpToggle("removeDuplicateConfirm", true)}
>
Delete all other accounts
</Button>
<Button
variant="outline_bg"
onClick={() => onRemoveDuplicateLater()}
className="flex-1 flex-grow"
>
Remind me later
</Button>
</div>
<Button
isLoading={logout.isPending}
variant="plain"
colorSchema="secondary"
className="mt-4"
onClick={handleLogout}
>
Change Account
</Button>
</div>
</form>
</div>
<div className="pb-28" />
<DeleteActionModal
isOpen={popUp.removeDuplicateConfirm.isOpen}
subTitle={`You’re currently logged in as ${duplicateAccounts?.data?.myAccount?.username}. Once you confirm, your other duplicate accounts will be permanently removed. Please make sure none of those accounts contain any production secrets, as this action cannot be undone.`}
title="Confirmation Required"
onChange={(isOpen) => handlePopUpToggle("removeDuplicateConfirm", isOpen)}
deleteKey="remove"
buttonText="Confirm"
onDeleteApproved={() =>
removeDuplicateEmails.mutateAsync(undefined, {
onSuccess: () => {
createNotification({
type: "success",
text: "Removed duplicate accounts"
});
onRemoveDuplicateLater();
}
})
}
/>
</div>
);
};
@@ -1,33 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { useState } from "react";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { Spinner } from "@app/components/v2";
import { useGetMyDuplicateAccount } from "@app/hooks/api";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
import { SelectOrganizationSection } from "./SelectOrgSection";
const LoadingScreen = () => {
return (
@@ -39,253 +16,18 @@ const LoadingScreen = () => {
};
export const SelectOrganizationPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const duplicateAccounts = useGetMyDuplicateAccount();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
const canBypassOrgAuth =
organization.bypassOrgAuthEnabled &&
organization.userRole === OrgMembershipRole.Admin &&
isAdminLogin;
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.location.href = url;
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
if (duplicateAccounts.isPending) {
return <LoadingScreen />;
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
return (
<EmailDuplicationConfirmation onRemoveDuplicateLater={() => setRemoveDuplicateLater(true)} />
);
}
<div className="space-y-1">
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
return <SelectOrganizationSection />;
};
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
const LoadingScreen = () => {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Spinner />
<p className="text-white opacity-80">Loading, please wait</p>
</div>
);
};
export const SelectOrganizationSection = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
const isUserOrgAdmin = organization.userRole === OrgMembershipRole.Admin;
const canBypassOrgAuth = organization.bypassOrgAuthEnabled && isUserOrgAdmin && isAdminLogin;
if (isAdminLogin) {
if (!organization.bypassOrgAuthEnabled) {
createNotification({
text: "This organization does not have bypass org auth enabled",
type: "error"
});
return;
}
if (!isUserOrgAdmin) {
createNotification({
text: "Only organization admins can bypass org auth",
type: "error"
});
return;
}
}
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.location.href = url;
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
return <LoadingScreen />;
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
<div className="space-y-1">
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
};
@@ -43,6 +43,11 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useGetKmipClientsByProjectId } from "@app/hooks/api/kmip";
@@ -71,7 +76,14 @@ export const KmipClientTable = () => {
perPage,
page,
setPerPage
} = usePagination(KmipClientOrderBy.Name);
} = usePagination(KmipClientOrderBy.Name, {
initPerPage: getUserTablePreference("kmipClientTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("kmipClientTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending, isFetching } = useGetKmipClientsByProjectId({
projectId,
@@ -290,7 +302,7 @@ export const KmipClientTable = () => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && kmipClients.length === 0 && (
@@ -53,6 +53,11 @@ import {
useWorkspace
} from "@app/context";
import { kmsKeyUsageOptions } from "@app/helpers/kms";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
@@ -100,7 +105,14 @@ export const CmekTable = () => {
perPage,
page,
setPerPage
} = usePagination(CmekOrderBy.Name);
} = usePagination(CmekOrderBy.Name, {
initPerPage: getUserTablePreference("cmekClientTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("cmekClientTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending, isFetching } = useGetCmeksByProjectId({
projectId,
@@ -508,7 +520,7 @@ export const CmekTable = () => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && keys.length === 0 && (
@@ -34,6 +34,11 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -103,7 +108,14 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, {
initPerPage: getUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredGroups = useMemo(() => {
const filtered = search
@@ -376,7 +388,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroups?.length && (
@@ -42,6 +42,11 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -76,7 +81,15 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
perPage,
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name, {
initPerPage: getUserTablePreference("identityTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage);
};
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const organizationId = currentOrg?.id || "";
@@ -379,7 +392,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && data && data?.identities.length === 0 && (
@@ -42,6 +42,11 @@ import {
useSubscription,
useUser
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import {
useFetchServerStatus,
@@ -170,7 +175,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, { initPerPage: 20 });
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, {
initPerPage: getUserTablePreference("orgMembersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("orgMembersTable", PreferenceKey.PerPage, newPerPage);
};
const filteredUsers = useMemo(
() =>
@@ -513,7 +525,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
@@ -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 { OnePassConnectionMethod, TOnePassConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TOnePassConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.OnePass)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(OnePassConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "API Token required"),
instanceUrl: z.string().trim().url("Invalid Connect Server instance URL")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const OnePassConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.OnePass,
method: OnePassConnectionMethod.ApiToken
}
});
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.OnePass].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(OnePassConnectionMethod).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 of the 1Password Connect Server instance to authenticate with."
>
<Input {...field} placeholder="https://1pass.example.com" />
</FormControl>
)}
/>
<Controller
name="credentials.apiToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="API 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 1Password"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -9,6 +9,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DiscriminativePick } from "@app/types";
import { AppConnectionHeader } from "../AppConnectionHeader";
import { OnePassConnectionForm } from "./1PasswordConnectionForm";
import { Auth0ConnectionForm } from "./Auth0ConnectionForm";
import { AwsConnectionForm } from "./AwsConnectionForm";
import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm";
@@ -104,6 +105,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <TeamCityConnectionForm onSubmit={onSubmit} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} />;
case AppConnection.OnePass:
return <OnePassConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -178,6 +181,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <TeamCityConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.OnePass:
return <OnePassConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
@@ -5,7 +5,7 @@ import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAppConnectionFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
});
@@ -19,7 +19,7 @@ import {
Tooltip
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { DistinguishedNameRegex } from "@app/helpers/string";
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import {
LdapConnectionMethod,
LdapConnectionProvider,
@@ -55,8 +55,13 @@ const formSchema = z.discriminatedUnion("method", [
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
.min(1, "DN/UPN required")
.refine(
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
password: z.string().trim().min(1, "Password required"),
sslRejectUnauthorized: z.boolean(),
sslCertificate: z
@@ -223,7 +228,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Binding Distinguished Name (DN)"
label="Binding DN/UPN"
>
<Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" />
</FormControl>
@@ -1,8 +1,11 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Spinner, Tooltip } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { usePopUp } from "@app/hooks";
import { useAppConnectionOptions } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -11,8 +14,11 @@ type Props = {
};
export const AppConnectionsSelect = ({ onSelect }: Props) => {
const { subscription } = useSubscription();
const { isPending, data: appConnectionOptions } = useAppConnectionOptions();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
@@ -25,13 +31,16 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => {
return (
<div className="grid grid-cols-4 gap-2">
{appConnectionOptions?.map((option) => {
const { image, name, size = 50 } = APP_CONNECTION_MAP[option.app];
const { image, name, size = 50, enterprise = false } = APP_CONNECTION_MAP[option.app];
return (
<button
type="button"
key={option.app}
onClick={() => onSelect(option.app)}
onClick={() =>
enterprise && !subscription.enterpriseAppConnections
? handlePopUpOpen("upgradePlan")
: onSelect(option.app)
}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
@@ -48,6 +57,11 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => {
</button>
);
})}
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use every App Connection if you switch to Infisical's Enterprise plan."
/>
<Tooltip
side="bottom"
className="max-w-sm py-4"
@@ -30,6 +30,11 @@ import {
Tr
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -76,7 +81,14 @@ export const AppConnectionsTable = () => {
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, { initPerPage: 20 });
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, {
initPerPage: getUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredAppConnections = useMemo(
() =>
@@ -282,7 +294,7 @@ export const AppConnectionsTable = () => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredAppConnections?.length && (
@@ -1,4 +1,9 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import {
faCircleCheck,
faCircleXmark,
faFileInvoice,
faInfoCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -10,6 +15,7 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
@@ -48,9 +54,26 @@ export const CurrentPlanSection = () => {
data &&
data?.rows?.length > 0 &&
data.rows.map(({ name, allowed, used }) => {
let toolTipText = null;
if (name === "Organization identity limit") {
toolTipText =
"Identity count is calculated by the total number of user identities and machine identities.";
}
return (
<Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{name}</Td>
<Td>
{name}
{toolTipText && (
<Tooltip content={toolTipText}>
<FontAwesomeIcon
icon={faInfoCircle}
className="relative bottom-2 left-2"
size="xs"
/>
</Tooltip>
)}
</Td>
<Td>{displayCell(allowed)}</Td>
<Td>{used}</Td>
</Tr>
@@ -25,6 +25,11 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -57,7 +62,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
offset,
orderDirection,
toggleOrderDirection
} = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 });
} = usePagination(GroupMembersOrderBy.Name, {
initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage);
};
const { currentOrg } = useOrganization();
@@ -163,7 +175,7 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
@@ -21,6 +21,11 @@ import {
THead,
Tr
} from "@app/components/v2";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -53,7 +58,14 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
offset,
orderDirection,
toggleOrderDirection
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
} = usePagination(IdentityProjectsOrderBy.Name, {
initPerPage: getUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredProjectMemberships = useMemo(
() =>
@@ -132,7 +144,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredProjectMemberships?.length && (
@@ -28,6 +28,11 @@ import {
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { getProjectHomePage } from "@app/helpers/project";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api";
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
@@ -104,7 +109,15 @@ export const AllProjectView = ({
limit,
toggleOrderDirection,
orderDirection
} = usePagination("name", { initPerPage: 50 });
} = usePagination("name", {
initPerPage: getUserTablePreference("allProjectsTable", PreferenceKey.PerPage, 50)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("allProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"requestAccessConfirmation"
] as const);
@@ -274,7 +287,7 @@ export const AllProjectView = ({
count={searchedProjects?.totalCount || 0}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isProjectLoading && !searchedProjects?.totalCount && (
@@ -19,6 +19,11 @@ import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { getProjectHomePage } from "@app/helpers/project";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetUserWorkspaces } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -63,7 +68,15 @@ export const MyProjectView = ({
limit,
toggleOrderDirection,
orderDirection
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
} = usePagination(ProjectOrderBy.Name, {
initPerPage: getUserTablePreference("myProjectsTable", PreferenceKey.PerPage, 24)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("myProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
useGetUserProjectFavorites(currentOrg?.id);
@@ -415,7 +428,7 @@ export const MyProjectView = ({
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{isWorkspaceEmpty && (
@@ -13,6 +13,11 @@ import {
useOrganization,
useServerConfig
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { withPermission } from "@app/hoc";
import { usePagination, usePopUp } from "@app/hooks";
import {
@@ -28,8 +33,6 @@ import { SecretScanningFilter } from "./components/SecretScanningFilters";
import { SecretScanningFilterFormData, secretScanningFilterFormSchema } from "./components/types";
import { SecretScanningLogsTable } from "./components";
const PER_PAGE_INIT = 25;
export const SecretScanningPage = withPermission(
() => {
const queryParams = useSearch({
@@ -49,9 +52,14 @@ export const SecretScanningPage = withPermission(
const { offset, limit, orderBy, setPage, perPage, page, setPerPage } = usePagination(
SecretScanningOrderBy.CreatedAt,
{ initPerPage: PER_PAGE_INIT }
{ initPerPage: getUserTablePreference("secretScanningTable", PreferenceKey.PerPage, 20) }
);
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("secretScanningTable", PreferenceKey.PerPage, newPerPage);
};
const repositoryNames = watch("repositoryNames");
const resolvedStatus = watch("resolved");
@@ -180,7 +188,7 @@ export const SecretScanningPage = withPermission(
</div>
)}
</div>
<div className="mt-8 space-y-3">
<div className="mt-8 space-y-2">
{integrationEnabled && (
<div className="flex w-full items-center justify-end">
<SecretScanningFilter
@@ -191,18 +199,16 @@ export const SecretScanningPage = withPermission(
</div>
)}
<SecretScanningLogsTable gitRisks={risksData?.risks} isPending={isPending} />
{!isPending &&
risksData?.totalCount !== undefined &&
risksData.totalCount >= PER_PAGE_INIT && (
<Pagination
className="rounded-md"
count={risksData.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && risksData?.totalCount !== undefined && risksData.totalCount >= 10 && (
<Pagination
className="rounded-md"
count={risksData.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</div>
</div>
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useEffect } from "react";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
@@ -129,7 +129,16 @@ export const OrgGeneralAuthSection = () => {
level.
</span>
<p className="mt-4">
In case of a lockout, admins can use the admin login portal at{" "}
In case of a lockout, admins can use the{" "}
<a
target="_blank"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal"
rel="noreferrer"
>
Admin Login Portal
</a>{" "}
at{" "}
<a
target="_blank"
rel="noopener noreferrer"
@@ -212,7 +212,16 @@ export const OrgOIDCSection = (): JSX.Element => {
level.
</span>
<p className="mt-4">
In case of a lockout, admins can use the admin login portal at{" "}
In case of a lockout, admins can use the{" "}
<a
target="_blank"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal"
rel="noreferrer"
>
Admin Login Portal
</a>{" "}
at{" "}
<a
target="_blank"
rel="noopener noreferrer"
@@ -20,6 +20,11 @@ import {
THead,
Tr
} from "@app/components/v2";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgUser } from "@app/hooks/api/types";
@@ -52,7 +57,14 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
offset,
orderDirection,
toggleOrderDirection
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
} = usePagination(UserGroupsOrderBy.Name, {
initPerPage: getUserTablePreference("userGroupsTable", PreferenceKey.PerPage, 10)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("userGroupsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredGroupMemberships = useMemo(
() =>
@@ -119,7 +131,7 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
@@ -22,6 +22,11 @@ import {
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -54,7 +59,14 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
offset,
orderDirection,
toggleOrderDirection
} = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 });
} = usePagination(UserProjectsOrderBy.Name, {
initPerPage: getUserTablePreference("userProjectsTable", PreferenceKey.PerPage, 10)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("userProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const { data: projectMemberships = [], isPending } = useGetOrgMembershipProjectMemberships(
orgId,
@@ -136,7 +148,7 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredProjectMemberships?.length && (
@@ -27,6 +27,11 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListWorkspaceGroups } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -62,7 +67,14 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
orderDirection,
orderBy,
toggleOrderDirection
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
} = usePagination(GroupsOrderBy.Name, {
initPerPage: getUserTablePreference("projectGroupsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectGroupsTable", PreferenceKey.PerPage, newPerPage);
};
const { data: groupMemberships = [], isPending } = useListWorkspaceGroups(
currentWorkspace?.id || ""
@@ -183,7 +195,7 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
@@ -42,6 +42,11 @@ import {
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { withProjectPermission } from "@app/hoc";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
@@ -72,7 +77,14 @@ export const IdentityTab = withProjectPermission(
perPage,
page,
setPerPage
} = usePagination(ProjectIdentityOrderBy.Name);
} = usePagination(ProjectIdentityOrderBy.Name, {
initPerPage: getUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, newPerPage);
};
const workspaceId = currentWorkspace?.id ?? "";
@@ -403,7 +415,7 @@ export const IdentityTab = withProjectPermission(
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
@@ -51,6 +51,11 @@ import {
useWorkspace
} from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -101,12 +106,12 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10)
initPerPage: getUserTablePreference("projectMembersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString());
setUserTablePreference("projectMembersTable", PreferenceKey.PerPage, newPerPage);
};
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(
@@ -13,6 +13,7 @@ import {
import {
PermissionConditionOperators,
ProjectPermissionCommitsActions,
ProjectPermissionApprovalActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,
ProjectPermissionIdentityActions,
@@ -53,6 +54,15 @@ const SecretPolicyActionSchema = z.object({
[ProjectPermissionSecretActions.Create]: z.boolean().optional()
});
const ApprovalPolicyActionSchema = z.object({
[ProjectPermissionApprovalActions.Read]: z.boolean().optional(),
[ProjectPermissionApprovalActions.Edit]: z.boolean().optional(),
[ProjectPermissionApprovalActions.Delete]: z.boolean().optional(),
[ProjectPermissionApprovalActions.Create]: z.boolean().optional(),
[ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional(),
[ProjectPermissionApprovalActions.AllowAccessBypass]: z.boolean().optional()
});
const CmekPolicyActionSchema = z.object({
read: z.boolean().optional(),
edit: z.boolean().optional(),
@@ -268,7 +278,7 @@ export const projectRoleFormSchema = z.object({
.array()
.default([]),
[ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: ApprovalPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
@@ -409,7 +419,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.PkiAlerts,
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.CertificateTemplates,
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Tags,
ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Kms,
@@ -571,6 +580,28 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return;
}
if (subject === ProjectPermissionSub.SecretApproval) {
const canCreate = action.includes(ProjectPermissionApprovalActions.Create);
const canDelete = action.includes(ProjectPermissionApprovalActions.Delete);
const canEdit = action.includes(ProjectPermissionApprovalActions.Edit);
const canRead = action.includes(ProjectPermissionApprovalActions.Read);
const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass);
const canAccessBypass = action.includes(ProjectPermissionApprovalActions.AllowAccessBypass);
if (!formVal[subject]) formVal[subject] = [{}];
// Map actions to the keys defined in ApprovalPolicyActionSchema
if (canCreate) formVal[subject]![0][ProjectPermissionApprovalActions.Create] = true;
if (canDelete) formVal[subject]![0][ProjectPermissionApprovalActions.Delete] = true;
if (canEdit) formVal[subject]![0][ProjectPermissionApprovalActions.Edit] = true;
if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true;
if (canChangeBypass)
formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true;
if (canAccessBypass)
formVal[subject]![0][ProjectPermissionApprovalActions.AllowAccessBypass] = true;
return;
}
if (subject === ProjectPermissionSub.SecretRollback) {
const canRead = action.includes(ProjectPermissionActions.Read);
const canCreate = action.includes(ProjectPermissionActions.Create);
@@ -1208,10 +1239,12 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
[ProjectPermissionSub.SecretApproval]: {
title: "Secret Approval Policies",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
{ label: "Read", value: ProjectPermissionApprovalActions.Read },
{ label: "Create", value: ProjectPermissionApprovalActions.Create },
{ label: "Modify", value: ProjectPermissionApprovalActions.Edit },
{ label: "Remove", value: ProjectPermissionApprovalActions.Delete },
{ label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass },
{ label: "Allow Access Bypass", value: ProjectPermissionApprovalActions.AllowAccessBypass }
]
},
[ProjectPermissionSub.SecretRotation]: {
@@ -1697,7 +1730,7 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
},
{
subject: ProjectPermissionSub.SecretApproval,
actions: Object.values(ProjectPermissionActions)
actions: Object.values(ProjectPermissionApprovalActions)
},
{
subject: ProjectPermissionSub.ServiceTokens,
@@ -32,6 +32,11 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
@@ -110,7 +115,14 @@ export const IntegrationsTable = ({
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, {
initPerPage: getUserTablePreference("integrationsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("integrationsTable", PreferenceKey.PerPage, newPerPage);
};
useEffect(() => {
if (integrations?.some((integration) => integration.isSynced === false))
@@ -437,7 +449,7 @@ export const IntegrationsTable = ({
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isLoading && !filteredIntegrations?.length && (
@@ -0,0 +1,14 @@
import { TOnePassSync } from "@app/hooks/api/secretSyncs/types/1password-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TOnePassSync;
};
export const OnePassSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -1,5 +1,6 @@
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { OnePassSyncDestinationCol } from "./1PasswordSyncDestinationCol";
import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDestinationCol";
import { AwsSecretsManagerSyncDestinationCol } from "./AwsSecretsManagerSyncDestinationCol";
import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfigurationDestinationSyncCol";
@@ -52,6 +53,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <TeamCitySyncDestinationCol secretSync={secretSync} />;
case SecretSync.OCIVault:
return <OCIVaultSyncDestinationCol secretSync={secretSync} />;
case SecretSync.OnePass:
return <OnePassSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -38,6 +38,11 @@ import {
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
@@ -119,7 +124,14 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => {
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<SecretSyncsOrderBy>(SecretSyncsOrderBy.Name, { initPerPage: 20 });
} = usePagination<SecretSyncsOrderBy>(SecretSyncsOrderBy.Name, {
initPerPage: getUserTablePreference("secretSyncTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("secretSyncTable", PreferenceKey.PerPage, newPerPage);
};
const filteredSecretSyncs = useMemo(
() =>
@@ -465,7 +477,7 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!filteredSecretSyncs?.length && (
@@ -106,6 +106,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.compartmentOcid;
secondaryText = destinationConfig.vaultOcid;
break;
case SecretSync.OnePass:
primaryText = destinationConfig.vaultId;
secondaryText = "Vault ID";
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -64,6 +64,11 @@ import {
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useCreateFolder,
@@ -180,7 +185,14 @@ export const OverviewPage = () => {
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name, {
initPerPage: getUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, 100)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, newPerPage);
};
const resetSelectedEntries = useCallback(() => {
setSelectedEntries({
@@ -1416,7 +1428,7 @@ export const OverviewPage = () => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
@@ -232,6 +232,7 @@ export const SecretEditRow = ({
environment={environment}
isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined}
canEditButNotView={secretValueHidden && !isOverride}
/>
)}
/>
@@ -44,8 +44,7 @@ export const SecretApprovalsPage = () => {
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 text-white">
<PageHeader
title="Approval Workflows"
description="Create approval policies for any modifications to secrets in sensitive environments and folders.
"
description="Create approval policies for any modifications to secrets in sensitive environments and folders."
>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
@@ -1,6 +1,6 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable react/jsx-no-useless-fragment */
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
faCheck,
faCheckCircle,
@@ -25,6 +25,7 @@ import {
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import {
ProjectPermissionApprovalActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
@@ -83,8 +84,9 @@ export const AccessApprovalRequest = ({
}) => {
const [selectedRequest, setSelectedRequest] = useState<
| (TAccessApprovalRequest & {
user: TWorkspaceUser["user"] | null;
user: { firstName?: string; lastName?: string; email?: string } | null;
isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean;
isApprover: boolean;
})
| null
@@ -100,6 +102,11 @@ export const AccessApprovalRequest = ({
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const canBypassApprovalPermission = permission.can(
ProjectPermissionApprovalActions.AllowAccessBypass,
ProjectPermissionSub.SecretApproval
);
const { data: members } = useGetWorkspaceUsers(projectId, true);
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
(prev, curr) => ({ ...prev, [curr.user.id]: curr }),
@@ -118,7 +125,7 @@ export const AccessApprovalRequest = ({
projectSlug
});
const { data: requests } = useGetAccessApprovalRequests({
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({
projectSlug,
authorProjectMembershipId: requestedByFilter,
envSlug: envFilter
@@ -143,56 +150,105 @@ export const AccessApprovalRequest = ({
return requests;
}, [requests, statusFilter, requestedByFilter, envFilter]);
const generateRequestDetails = (request: TAccessApprovalRequest) => {
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED
);
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.allowedSelfApprovals;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => {
const isReviewedByUser =
request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED
);
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.allowedSelfApprovals;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
label: "",
type: "primary"
};
const isExpired =
request.privilege &&
request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`,
type: "primary"
};
} else if (!isReviewedByUser)
displayData = {
label: "Review Required",
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
label: "",
type: "primary"
};
return {
displayData,
isReviewedByUser,
isRejectedByAnyone,
isApprover,
userReviewStatus,
isAccepted,
isSoftEnforcement,
isRequestedByCurrentUser,
isSelfApproveAllowed
};
};
const isExpired =
request.privilege &&
request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`,
type: "primary"
};
} else if (!isReviewedByUser)
displayData = {
label: "Review Required",
type: "primary"
};
return {
displayData,
isReviewedByUser,
isRejectedByAnyone,
isApprover,
userReviewStatus,
isAccepted,
isSoftEnforcement,
isRequestedByCurrentUser,
isSelfApproveAllowed
};
},
[user]
);
const handleSelectRequest = useCallback(
(request: TAccessApprovalRequest) => {
const details = generateRequestDetails(request);
// Whether the request has already been approved / rejected / reviewed
const isInactive =
details.isAccepted || details.isReviewedByUser || details.isRejectedByAnyone;
// Whether the current user can bypass policy
const canBypass =
details.isSoftEnforcement &&
details.isRequestedByCurrentUser &&
canBypassApprovalPermission;
// Whether the current user can approve
const canApprove =
details.isApprover && (!details.isRequestedByCurrentUser || details.isSelfApproveAllowed);
if (isInactive || (!canApprove && !canBypass)) return;
if (membersGroupById?.[request.requestedByUserId].user || details.isRequestedByCurrentUser) {
setSelectedRequest({
...request,
user:
details.isRequestedByCurrentUser || !membersGroupById?.[request.requestedByUserId].user
? user
: membersGroupById?.[request.requestedByUserId].user,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isSelfApproveAllowed: details.isSelfApproveAllowed,
isApprover: details.isApprover
});
}
handlePopUpOpen("reviewRequest");
},
[
generateRequestDetails,
canBypassApprovalPermission,
membersGroupById,
user,
setSelectedRequest,
handlePopUpOpen
]
);
return (
<div>
@@ -344,50 +400,10 @@ export const AccessApprovalRequest = ({
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button"
tabIndex={0}
onClick={() => {
if (
((!details.isApprover ||
details.isReviewedByUser ||
details.isRejectedByAnyone ||
details.isAccepted) &&
!(
details.isSoftEnforcement &&
details.isRequestedByCurrentUser &&
!details.isAccepted
)) ||
(request.requestedByUserId === user.id && !details.isSelfApproveAllowed)
)
return;
if (membersGroupById?.[request.requestedByUserId].user) {
setSelectedRequest({
...request,
user: membersGroupById?.[request.requestedByUserId].user,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isApprover: details.isApprover
});
}
handlePopUpOpen("reviewRequest");
}}
onClick={() => handleSelectRequest(request)}
onKeyDown={(evt) => {
if (
!details.isApprover ||
details.isAccepted ||
details.isReviewedByUser ||
details.isRejectedByAnyone
)
return;
if (evt.key === "Enter") {
if (membersGroupById?.[request.requestedByUserId].user) {
setSelectedRequest({
...request,
user: membersGroupById?.[request.requestedByUserId].user,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isApprover: details.isApprover
});
}
handlePopUpOpen("reviewRequest");
handleSelectRequest(request);
}
}}
>
@@ -453,7 +469,9 @@ export const AccessApprovalRequest = ({
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
refetchRequests();
}}
canBypassApprovalPermission={canBypassApprovalPermission}
/>
)}
@@ -1,14 +1,16 @@
import { useCallback, useMemo, useState } from "react";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2";
import { Button, Checkbox, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions } from "@app/context";
import { useReviewAccessRequest } from "@app/hooks/api";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { TWorkspaceUser } from "@app/hooks/api/types";
export const ReviewAccessRequestModal = ({
isOpen,
@@ -16,21 +18,26 @@ export const ReviewAccessRequestModal = ({
request,
projectSlug,
selectedRequester,
selectedEnvSlug
selectedEnvSlug,
canBypassApprovalPermission
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
request: TAccessApprovalRequest & {
user: TWorkspaceUser["user"] | null;
user: { firstName?: string; lastName?: string; email?: string } | null;
isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean;
isApprover: boolean;
};
projectSlug: string;
selectedRequester: string | undefined;
selectedEnvSlug: string | undefined;
canBypassApprovalPermission: boolean;
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [byPassApproval, setByPassApproval] = useState(false);
const [bypassApproval, setBypassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState("");
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const accessDetails = {
@@ -80,31 +87,52 @@ export const ReviewAccessRequestModal = ({
const reviewAccessRequest = useReviewAccessRequest();
const handleReview = useCallback(async (status: "approved" | "rejected") => {
setIsLoading(status);
try {
await reviewAccessRequest.mutateAsync({
requestId: request.id,
status,
projectSlug,
envSlug: selectedEnvSlug,
requestedBy: selectedRequester
});
} catch (error) {
console.error(error);
const handleReview = useCallback(
async (status: "approved" | "rejected") => {
if (bypassApproval && bypassReason.length < 10) {
createNotification({
title: "Failed to bypass approval",
text: "Reason must be 10 characters or longer",
type: "error"
});
return;
}
setIsLoading(status);
try {
await reviewAccessRequest.mutateAsync({
requestId: request.id,
status,
projectSlug,
envSlug: selectedEnvSlug,
requestedBy: selectedRequester,
bypassReason: bypassApproval ? bypassReason : undefined
});
createNotification({
title: `Request ${status}`,
text: `The request has been ${status}`,
type: status === "approved" ? "success" : "info"
});
} catch (error) {
console.error(error);
setIsLoading(null);
return;
}
setIsLoading(null);
return;
}
createNotification({
title: `Request ${status}`,
text: `The request has been ${status}`,
type: status === "approved" ? "success" : "info"
});
setIsLoading(null);
onOpenChange(false);
}, []);
onOpenChange(false);
},
[
bypassApproval,
bypassReason,
reviewAccessRequest,
request,
selectedEnvSlug,
selectedRequester,
onOpenChange
]
);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -115,12 +143,17 @@ export const ReviewAccessRequestModal = ({
>
<div className="text-sm">
<span>
<span className="font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span>{" "}
{request.user &&
(request.user.firstName || request.user.lastName) &&
request.user.email ? (
<span className="font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span>
) : (
<span>A user</span>
)}{" "}
is requesting access to the following resource:
</span>
<div className="mb-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
<div className="mb-1 lowercase">
<span className="font-bold capitalize">Requested path: </span>
@@ -144,12 +177,16 @@ export const ReviewAccessRequestModal = ({
</div>
)}
</div>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
!!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
@@ -168,21 +205,42 @@ export const ReviewAccessRequestModal = ({
Reject Request
</Button>
</div>
{isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && (
<div className="mt-4">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""}
>
<span className="text-sm text-red">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
</div>
)}
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypassApprovalPermission && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
</div>
</ModalContent>
</Modal>
@@ -29,13 +29,13 @@ import {
Tr
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
TProjectPermission,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import {
useDeleteAccessApprovalPolicy,
@@ -61,8 +61,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?:
projectSlug: currentWorkspace?.slug as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.slug
permission.can(
ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
) && !!currentWorkspace?.slug
}
}
);
@@ -71,8 +73,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?:
workspaceId: currentWorkspace?.id as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.id
permission.can(
ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
) && !!currentWorkspace?.id
}
}
);
@@ -160,7 +164,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
I={ProjectPermissionApprovalActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
@@ -126,8 +126,6 @@ export const AccessPolicyForm = ({
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const approversRequired = watch("approvals") || 1;
const handleCreatePolicy = async ({
environment,
groupApprovers,
@@ -303,73 +301,6 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
<>
<p>
Determines the level of enforcement for required approvers of a request:
</p>
<p className="mt-2">
<span className="font-bold">Hard</span> enforcement requires at least{" "}
<span className="font-bold"> {approversRequired}</span> approver(s) to
approve the request.`
</p>
<p className="mt-2">
<span className="font-bold">Soft</span> enforcement At least{" "}
<span className="font-bold">{approversRequired}</span> approver(s) must
approve the request; however, the requester can bypass approval
requirements in emergencies.
</p>
</>
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem value={level} key={`enforcement-level-${level}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
@@ -386,6 +317,28 @@ export const AccessPolicyForm = ({
)}
/>
</div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
@@ -465,6 +418,29 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Bypass Approvals"
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
id="bypass-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value === EnforcementLevel.Soft}
onCheckedChange={(v) =>
onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)
}
>
Allow request creators to bypass policy in break-glass situations
</Switch>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
@@ -14,7 +14,8 @@ import {
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies";
import { Approver } from "@app/hooks/api/accessApproval/types";
@@ -117,7 +118,7 @@ export const ApprovalPolicyRow = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionApprovalActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
@@ -136,7 +137,7 @@ export const ApprovalPolicyRow = ({
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionApprovalActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
@@ -4,16 +4,20 @@ import {
faClose,
faLandMineOn,
faLockOpen,
faSquareCheck,
faSquareXmark,
faTriangleExclamation,
faUserLock
faUserLock,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, FormControl, Input } from "@app/components/v2";
import {
ProjectPermissionApprovalActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import {
usePerformSecretApprovalRequestMerge,
useUpdateSecretApprovalRequestStatus
@@ -49,6 +53,12 @@ export const SecretApprovalRequestAction = ({
const { mutateAsync: updateSecretStatusChange, isPending: isStatusChanging } =
useUpdateSecretApprovalRequestStatus();
const { permission } = useProjectPermission();
const canBypassApprovalPermission = permission.can(
ProjectPermissionApprovalActions.AllowChangeBypass,
ProjectPermissionSub.SecretApproval
);
const [byPassApproval, setByPassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState("");
@@ -101,62 +111,76 @@ export const SecretApprovalRequestAction = ({
if (!hasMerged && status === "open") {
return (
<div className="flex w-full items-start justify-between transition-all">
<div className="flex items-start space-x-4">
<FontAwesomeIcon
icon={isMergable ? faSquareCheck : faSquareXmark}
className={twMerge("pt-1 text-2xl", isMergable ? "text-primary" : "text-red-600")}
/>
<div className="flex w-full flex-col items-start justify-between py-4 transition-all">
<div className="flex items-center space-x-4 px-4">
<div
className={`flex items-center justify-center rounded-full ${isMergable ? "h-10 w-10 bg-green" : "h-11 w-11 bg-red-600"}`}
>
<FontAwesomeIcon
icon={isMergable ? faCheck : faXmark}
className={isMergable ? "text-lg text-black" : "text-2xl text-white"}
/>
</div>
<span className="flex flex-col">
{isMergable ? "Good to merge" : "Review required"}
<span className="inline-block text-xs text-bunker-200">
At least {approvals} approving review required
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
{isSoftEnforcement && !isMergable && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-xs text-red">
Merge without waiting for approval (bypass secret change policy)
</span>
</Checkbox>
{byPassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.target.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
{isMergable ? "Good to merge" : "Merging is blocked"}
</p>
{!isMergable && (
<span className="inline-block text-xs text-bunker-200">
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
eligible reviewers.
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
)}
</span>
</div>
<div className="flex items-center space-x-2">
<div
className={`mt-4 w-full border-mineshaft-600 px-5 ${isMergable ? "border-t pb-2" : "border-y pb-4"}`}
>
{isSoftEnforcement && !isMergable && canBypassApprovalPermission && (
<div className="mt-2 flex flex-col space-y-2 pt-2">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-sm">
Merge without waiting for approval (bypass secret change policy)
</span>
</Checkbox>
{byPassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.target.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
</div>
<div className="mt-2 flex w-full items-center justify-end space-x-2 px-4">
{canApprove || isSoftEnforcement ? (
<>
<div className="flex items-center space-x-4">
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="secondary"
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
className="hover:border-red/60 hover:bg-red/10"
>
Close request
</Button>
@@ -175,7 +199,7 @@ export const SecretApprovalRequestAction = ({
>
Merge
</Button>
</>
</div>
) : (
<div>Only approvers can merge</div>
)}
@@ -186,13 +210,13 @@ export const SecretApprovalRequestAction = ({
if (hasMerged && status === "close")
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-start space-x-4">
<div className="flex w-full items-center justify-between rounded-md border border-primary/60 bg-primary/10">
<div className="flex items-start space-x-4 p-4">
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
<span className="flex flex-col">
Secret approval merged
Change request merged
<span className="inline-block text-xs text-bunker-200">
Merged by {statusChangeByEmail}
Merged by {statusChangeByEmail}.
</span>
</span>
</div>
@@ -1,18 +1,18 @@
import { faExclamationTriangle, faInfo, faKey } from "@fortawesome/free-solid-svg-icons";
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable no-nested-ternary */
import { useState } from "react";
import {
faCircleXmark,
faExclamationTriangle,
faEye,
faEyeSlash,
faInfo,
faKey
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
SecretInput,
Table,
TableContainer,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Tag, Tooltip } from "@app/components/v2";
import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
export type Props = {
@@ -29,19 +29,19 @@ export type Props = {
const generateItemTitle = (op: CommitType) => {
let text = { label: "", color: "" };
if (op === CommitType.CREATE) text = { label: "create", color: "#16a34a" };
else if (op === CommitType.UPDATE) text = { label: "change", color: "#ea580c" };
else text = { label: "deletion", color: "#b91c1c" };
if (op === CommitType.CREATE) text = { label: "create", color: "#60DD00" };
else if (op === CommitType.UPDATE) text = { label: "change", color: "#F8EB30" };
else text = { label: "deletion", color: "#F83030" };
return (
<span>
<div className="text-md pb-2 font-medium">
Request for <span style={{ color: text.color }}>secret {text.label}</span>
</span>
</div>
);
};
const generateConflictText = (op: CommitType) => {
if (op === CommitType.CREATE) return <div>Secret already exist</div>;
if (op === CommitType.CREATE) return <div>Secret already exists</div>;
if (op === CommitType.UPDATE) return <div>Secret not found</div>;
return null;
};
@@ -59,10 +59,12 @@ export const SecretApprovalRequestChangeItem = ({
const itemConflict =
hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?.id);
const hasConflict = Boolean(itemConflict);
const [isOldSecretValueVisible, setIsOldSecretValueVisible] = useState(false);
const [isNewSecretValueVisible, setIsNewSecretValueVisible] = useState(false);
return (
<div className="rounded-lg bg-bunker-500 px-4 pb-4 pt-2">
<div className="flex items-center px-1 py-3">
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 pb-4 pt-2">
<div className="flex items-center px-1 py-1">
<div className="flex-grow">{generateItemTitle(op)}</div>
{!hasMerged && isStale && (
<div className="flex items-center">
@@ -79,48 +81,86 @@ export const SecretApprovalRequestChangeItem = ({
</div>
)}
</div>
<TableContainer>
<Table>
<THead>
<Tr>
{op === CommitType.UPDATE && <Th className="w-12 shrink-0" />}
<Th className="w-48 shrink-0">Secret</Th>
<Th className="min-w-0 flex-1">Value</Th>
<Th className="w-24 shrink-0">Comment</Th>
<Th className="w-24 shrink-0">Tags</Th>
<Th className="w-40 shrink-0">Metadata</Th>
</Tr>
</THead>
{op === CommitType.UPDATE ? (
<TBody>
<Tr>
<Td className="text-red-600">OLD</Td>
<Td>{secretVersion?.secretKey}</Td>
<Td>
<div>
<div className="flex flex-col space-x-0 space-y-4 xl:flex-row xl:space-x-4 xl:space-y-0">
{op === CommitType.UPDATE || op === CommitType.DELETE ? (
<div className="flex w-full cursor-default flex-col rounded-md border border-red-600/60 bg-red-600/10 p-4 xl:w-1/2">
<div className="mb-4 flex flex-row justify-between">
<span className="text-md font-medium">Legacy Secret</span>
<div className="rounded-full bg-red px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
Deprecated
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Key</div>
<div className="text-sm">{secretVersion?.secretKey} </div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Value</div>
<div className="text-sm">
{newVersion?.isRotatedSecret ? (
<span className="text-mineshaft-400">
Rotated Secret value will not be affected
</span>
) : (
<SecretInput isReadOnly value={secretVersion?.secretValue} />
)}
</Td>
<Td>{secretVersion?.secretComment}</Td>
<Td className="flex flex-wrap gap-2">
{secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${secretVersion.id}-${tagId}`}
<div
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2"
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))}
</Td>
<Td>
className={`flex font-mono ${isOldSecretValueVisible || !secretVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`}
>
{isOldSecretValueVisible
? secretVersion?.secretValue || "EMPTY"
: secretVersion?.secretValue
? secretVersion?.secretValue?.split("").map(() => "•")
: "EMPTY"}{" "}
</div>
{secretVersion?.secretValue && (
<div className="flex h-10 w-10 items-center justify-center">
<FontAwesomeIcon
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
)}
</div>
)}
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
<div className="text-sm">
{secretVersion?.secretComment || (
<span className="text-sm text-mineshaft-300">-</span>
)}{" "}
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
<div className="flex flex-wrap gap-2">
{(secretVersion?.tags?.length ?? 0) ? (
secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${secretVersion.id}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))
) : (
<span className="text-sm text-mineshaft-300">-</span>
)}
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Metadata</div>
<div>
{secretVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{secretVersion.secretMetadata?.map((el) => (
@@ -146,93 +186,77 @@ export const SecretApprovalRequestChangeItem = ({
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</Td>
</Tr>
<Tr>
<Td className="text-green-600">NEW</Td>
<Td>{newVersion?.secretKey}</Td>
<Td>
</div>
</div>
</div>
) : (
<div className="text-md flex w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 text-mineshaft-300 xl:w-1/2">
{" "}
Secret not existent in the previous version.
</div>
)}
{op === CommitType.UPDATE || op === CommitType.CREATE ? (
<div className="flex w-full cursor-default flex-col rounded-md border border-green-600/60 bg-green-600/10 p-4 xl:w-1/2">
<div className="mb-4 flex flex-row justify-between">
<span className="text-md font-medium">New Secret</span>
<div className="rounded-full bg-green-600 px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
Current
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Key</div>
<div className="text-sm">{newVersion?.secretKey} </div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Value</div>
<div className="text-sm">
{newVersion?.isRotatedSecret ? (
<span className="text-mineshaft-400">
Rotated Secret value will not be affected
</span>
) : (
<SecretInput isReadOnly value={newVersion?.secretValue} />
)}
</Td>
<Td>{newVersion?.secretComment}</Td>
<Td className="flex flex-wrap gap-2">
{newVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${newVersion.id}-${tagId}`}
<div
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2"
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))}
</Td>
<Td>
{newVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{newVersion.secretMetadata?.map((el) => (
<div key={el.key} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
className={`flex font-mono ${isNewSecretValueVisible || !newVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`}
>
{isNewSecretValueVisible
? newVersion?.secretValue || "EMPTY"
: newVersion?.secretValue
? newVersion?.secretValue?.split("").map(() => "•")
: "EMPTY"}{" "}
</div>
{newVersion?.secretValue && (
<div className="flex h-10 w-10 items-center justify-center">
<FontAwesomeIcon
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
))}
)}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</Td>
</Tr>
</TBody>
) : (
<TBody>
<Tr>
<Td>
{op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.secretKey}
</Td>
<Td>
<SecretInput
isReadOnly
value={
op === CommitType.CREATE
? newVersion?.secretValue
: secretVersion?.secretValue
}
/>
</Td>
<Td>
{op === CommitType.CREATE
? newVersion?.secretComment
: secretVersion?.secretComment}
</Td>
<Td>
{(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map(
({ slug, id: tagId, color }) => (
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
<div className="text-sm">
{newVersion?.secretComment || (
<span className="text-sm text-mineshaft-300">-</span>
)}{" "}
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
<div className="flex flex-wrap gap-2">
{(newVersion?.tags?.length ?? 0) ? (
newVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${
op === CommitType.CREATE ? newVersion?.id : secretVersion?.id
}-${tagId}`}
key={`${newVersion.id}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
@@ -240,41 +264,49 @@ export const SecretApprovalRequestChangeItem = ({
/>
<div className="text-sm">{slug}</div>
</Tag>
)
)}
</Td>
<Td>
{newVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{newVersion.secretMetadata?.map((el) => (
<div key={el.key} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
))
) : (
<p className="text-sm text-mineshaft-300">-</p>
<span className="text-sm text-mineshaft-300">-</span>
)}
</Td>
</Tr>
</TBody>
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Metadata</div>
{newVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{newVersion.secretMetadata?.map((el) => (
<div key={el.key} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</div>
</div>
) : (
<div className="text-md flex w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 text-mineshaft-300 xl:w-1/2">
{" "}
Secret not existent in the new version.
</div>
)}
</Table>
</TableContainer>
</div>
</div>
</div>
);
};
@@ -13,6 +13,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from "@radix-ui/react-radio-group";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import z from "zod";
@@ -51,7 +52,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => {
text.push(
<span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#16a34a" }}> created</span>
<span style={{ color: "#60DD00" }}> created</span>
</span>
);
if (score[CommitType.UPDATE])
@@ -59,7 +60,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => {
<span key="updated-commit">
{Boolean(text.length) && ","}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#ea580c" }} className="text-orange-600">
<span style={{ color: "#F8EB30" }} className="text-orange-600">
{" "}
updated
</span>
@@ -70,7 +71,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => {
<span className="deleted-commit">
{Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#b91c1c" }}> deleted</span>
<span style={{ color: "#F83030" }}> deleted</span>
</span>
);
@@ -221,29 +222,32 @@ export const SecretApprovalRequestChanges = ({
</span>
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg">
<div className="text-lg">
{generateCommitText(secretApprovalRequestDetails.commits)}
{secretApprovalRequestDetails.isReplicated && (
<span className="text-sm text-bunker-300"> (replication)</span>
)}
</div>
<div className="flex items-center text-sm text-bunker-300">
{secretApprovalRequestDetails?.committerUser?.firstName || ""}
{secretApprovalRequestDetails?.committerUser?.lastName || ""} (
{secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "}
{secretApprovalRequestDetails.commits.length} secret values in
<span className="mx-1 rounded bg-primary-600/60 px-1 text-primary-300">
<div className="text-sm text-bunker-300">
<p className="inline">
{secretApprovalRequestDetails?.committerUser?.firstName || ""}
{secretApprovalRequestDetails?.committerUser?.lastName || ""} (
{secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "}
{secretApprovalRequestDetails.commits.length} secret values in
</p>
<p className="mx-1 inline rounded bg-primary-600/40 px-1 py-1 text-primary-300">
{secretApprovalRequestDetails.environment}
</span>
<div className="flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<div className="border-r border-mineshaft-500 pr-1">
</p>
<div className="inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
</div>
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
<div className="truncate pb-0.5 pl-2 text-sm" style={{ maxWidth: "10rem" }}>
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
</div>
</Tooltip>
</p>
<p
className="cursor-default truncate pb-0.5 pl-2 text-sm"
style={{ maxWidth: "10rem" }}
>
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
</p>
</div>
</div>
</div>
@@ -255,17 +259,14 @@ export const SecretApprovalRequestChanges = ({
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline_bg"
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
>
<Button rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}>
Review
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" asChild className="mt-3">
<form onSubmit={handleSubmit(handleSubmitReview)}>
<div className="flex w-[400px] flex-col space-y-2 p-5">
<div className="text-lg font-medium">Finish your review</div>
<div className="flex w-[500px] flex-col space-y-2 p-5">
<div className="text-md font-medium">Finish your review</div>
<Controller
control={control}
name="comment"
@@ -275,7 +276,7 @@ export const SecretApprovalRequestChanges = ({
{...field}
placeholder="Leave a comment..."
reSize="none"
className="text-md mt-2 h-48 border border-mineshaft-600 bg-bunker-800"
className="text-md mt-2 h-40 border border-mineshaft-600 bg-bunker-800"
/>
</FormControl>
)}
@@ -320,7 +321,7 @@ export const SecretApprovalRequestChanges = ({
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
className="h-4 w-4 rounded-full border border-gray-300 text-red focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
@@ -376,14 +377,14 @@ export const SecretApprovalRequestChanges = ({
)
)}
</div>
<div className="mt-4 flex flex-col items-center rounded-lg">
<div className="my-4 flex flex-col items-center rounded-lg border border-mineshaft-600">
{secretApprovalRequestDetails?.policy?.approvers
.filter((requiredApprover) => reviewedUsers?.[requiredApprover.userId])
.map((requiredApprover) => {
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="mb-4 flex w-full flex-col rounded-md bg-mineshaft-800 p-6"
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4"
key={`required-approver-${requiredApprover.userId}`}
>
<div>
@@ -396,10 +397,11 @@ export const SecretApprovalRequestChanges = ({
>
{reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"}
</span>{" "}
the request.
the request on{" "}
{format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}.
</div>
{reviewer?.comment && (
<FormControl label="Comment" className="mb-0 mt-2">
<FormControl label="Comment" className="mb-0 mt-4">
<TextArea value={reviewer.comment} isDisabled reSize="none">
{reviewer?.comment && reviewer.comment}
</TextArea>
@@ -409,7 +411,7 @@ export const SecretApprovalRequestChanges = ({
);
})}
</div>
<div className="flex items-center space-x-6 rounded-lg bg-mineshaft-800 px-5 py-6">
<div className="mt-2 flex items-center space-x-6 rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<SecretApprovalRequestAction
canApprove={canApprove}
approvalRequestId={secretApprovalRequestDetails.id}
@@ -423,7 +425,7 @@ export const SecretApprovalRequestChanges = ({
/>
</div>
</div>
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
<div className="sticky top-0 w-1/5 cursor-default pt-4" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers
@@ -435,10 +437,10 @@ export const SecretApprovalRequestChanges = ({
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
className="flex flex-nowrap items-center justify-between space-x-2 rounded border border-mineshaft-600 bg-mineshaft-800 px-2 py-1"
key={`required-approver-${requiredApprover.userId}`}
>
<div className="flex-grow text-sm">
<div className="flex text-sm">
<Tooltip
content={`${requiredApprover.firstName || ""} ${
requiredApprover.lastName || ""
@@ -458,7 +460,7 @@ export const SecretApprovalRequestChanges = ({
/>
</Tooltip>
)}
<Tooltip content={reviewer?.status || ApprovalStatus.PENDING}>
<Tooltip content={`Status: ${reviewer?.status || ApprovalStatus.PENDING}`}>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip>
</div>
@@ -29,6 +29,11 @@ import {
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions
} from "@app/context/ProjectPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useGetImportedSecretsSingleEnv,
@@ -99,7 +104,14 @@ const Page = () => {
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name, {
initPerPage: getUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, 100)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, newPerPage);
};
const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId);
@@ -604,7 +616,7 @@ const Page = () => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
<Modal
@@ -58,7 +58,7 @@ import {
} from "./SecretListView.utils";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
const hiddenValue = "******";
export const HIDDEN_SECRET_VALUE = "******";
type Props = {
secret: SecretV3RawSanitized;
@@ -122,7 +122,7 @@ export const SecretItem = memo(
const getDefaultValue = () => {
if (secret.secretValueHidden) {
return canEditSecretValue ? hiddenValue : "";
return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
}
return secret.valueOverride || secret.value || "";
};
@@ -366,10 +366,11 @@ export const SecretItem = memo(
isReadOnly={isReadOnly || isRotatedSecret}
key="secret-value"
isVisible={isVisible && !secretValueHidden}
canEditButNotView={secretValueHidden && !isOverriden}
environment={environment}
secretPath={secretPath}
{...field}
defaultValue={secretValueHidden ? hiddenValue : undefined}
defaultValue={secretValueHidden ? HIDDEN_SECRET_VALUE : undefined}
containerClassName="py-1.5 rounded-md transition-all"
/>
)}
@@ -0,0 +1,14 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TOnePassSync } from "@app/hooks/api/secretSyncs/types/1password-sync";
type Props = {
secretSync: TOnePassSync;
};
export const OnePassSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { vaultId }
} = secretSync;
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
};
@@ -10,6 +10,7 @@ import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissi
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { OnePassSyncDestinationSection } from "./1PasswordSyncDestinationSection";
import { AwsParameterStoreSyncDestinationSection } from "./AwsParameterStoreSyncDestinationSection";
import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSyncDestinationSection";
import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection";
@@ -85,6 +86,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.OCIVault:
DestinationComponents = <OCIVaultSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.OnePass:
DestinationComponents = <OnePassSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}

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