mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 13:22:08 +00:00
Merge branch 'main' into ENG-2807
This commit is contained in:
+1
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
+2
-6
@@ -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,
|
||||
|
||||
+111
-16
@@ -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'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="-_.~!*"
|
||||
|
||||
+24
-2
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
label: "Parameters" | "Secrets Mapping";
|
||||
label: "Parameters" | "Secrets Mapping" | "Password Requirements";
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -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>>;
|
||||
|
||||
+18
-6
@@ -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);
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type TPasswordRequirements = z.infer<typeof PasswordRequirementsSchema>;
|
||||
|
||||
export const PasswordRequirementsSchema = z
|
||||
.object({
|
||||
length: z
|
||||
|
||||
@@ -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,14 @@ export enum ProjectPermissionSecretActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionApprovalActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
AllowChangeBypass = "allow-change-bypass"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionDynamicSecretActions {
|
||||
ReadRootCredential = "read-root-credential",
|
||||
CreateRootCredential = "create-root-credential",
|
||||
@@ -285,7 +293,7 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval]
|
||||
| [
|
||||
ProjectPermissionIdentityActions,
|
||||
(
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
export type { TProjectPermission } from "./ProjectPermissionContext";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
|
||||
@@ -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,}$/;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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'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‘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‘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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
+9
-4
@@ -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>
|
||||
|
||||
+25
-2
@@ -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>
|
||||
|
||||
+35
-7
@@ -12,6 +12,7 @@ import {
|
||||
} from "@app/context";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionGroupActions,
|
||||
ProjectPermissionIdentityActions,
|
||||
@@ -52,6 +53,14 @@ 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()
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
@@ -261,7 +270,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([]),
|
||||
@@ -402,7 +411,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
ProjectPermissionSub.CertificateTemplates,
|
||||
ProjectPermissionSub.SecretApproval,
|
||||
ProjectPermissionSub.Tags,
|
||||
ProjectPermissionSub.SecretRotation,
|
||||
ProjectPermissionSub.Kms,
|
||||
@@ -564,6 +572,25 @@ 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);
|
||||
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretRollback) {
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canCreate = action.includes(ProjectPermissionActions.Create);
|
||||
@@ -1181,10 +1208,11 @@ 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 }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretRotation]: {
|
||||
@@ -1661,7 +1689,7 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretApproval,
|
||||
actions: Object.values(ProjectPermissionActions)
|
||||
actions: Object.values(ProjectPermissionApprovalActions)
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.ServiceTokens,
|
||||
|
||||
+1
@@ -232,6 +232,7 @@ export const SecretEditRow = ({
|
||||
environment={environment}
|
||||
isImport={isImportedSecret}
|
||||
defaultValue={secretValueHidden ? "" : undefined}
|
||||
canEditButNotView={secretValueHidden && !isOverride}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+10
-6
@@ -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) => (
|
||||
|
||||
+45
-69
@@ -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
|
||||
|
||||
+4
-3
@@ -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) => (
|
||||
|
||||
+12
-1
@@ -14,6 +14,11 @@ 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 +54,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("");
|
||||
|
||||
@@ -113,7 +124,7 @@ export const SecretApprovalRequestAction = ({
|
||||
At least {approvals} approving review required
|
||||
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
|
||||
</span>
|
||||
{isSoftEnforcement && !isMergable && (
|
||||
{isSoftEnforcement && !isMergable && canBypassApprovalPermission && (
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
||||
|
||||
+4
-3
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user