Merge branch 'main' into feat/showWarningOnImportedSecretDeletion

This commit is contained in:
carlosmonastyrski
2025-04-14 09:04:47 -03:00
423 changed files with 14637 additions and 1130 deletions
@@ -33,14 +33,12 @@ import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/Identity
import { AuthPanel } from "./components/AuthPanel";
import { EncryptionPanel } from "./components/EncryptionPanel";
import { IntegrationPanel } from "./components/IntegrationPanel";
import { RateLimitPanel } from "./components/RateLimitPanel";
import { UserPanel } from "./components/UserPanel";
enum TabSections {
Settings = "settings",
Encryption = "encryption",
Auth = "auth",
RateLimit = "rate-limit",
Integrations = "integrations",
Users = "users",
Identities = "identities",
@@ -163,7 +161,6 @@ export const OverviewPage = () => {
<Tab value={TabSections.Settings}>General</Tab>
<Tab value={TabSections.Encryption}>Encryption</Tab>
<Tab value={TabSections.Auth}>Authentication</Tab>
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>User Identities</Tab>
<Tab value={TabSections.Identities}>Machine Identities</Tab>
@@ -262,7 +259,6 @@ export const OverviewPage = () => {
<SelectClear
selectValue={defaultAuthOrgId}
onClear={() => {
console.log("clearing");
onChange("");
}}
>
@@ -403,9 +399,6 @@ export const OverviewPage = () => {
<TabPanel value={TabSections.Auth}>
<AuthPanel />
</TabPanel>
<TabPanel value={TabSections.RateLimit}>
<RateLimitPanel />
</TabPanel>
<TabPanel value={TabSections.Integrations}>
<IntegrationPanel />
</TabPanel>
@@ -1,240 +0,0 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api";
const formSchema = z.object({
readRateLimit: z.number(),
writeRateLimit: z.number(),
secretsRateLimit: z.number(),
authRateLimit: z.number(),
inviteUserRateLimit: z.number(),
mfaRateLimit: z.number(),
publicEndpointLimit: z.number()
});
type TRateLimitForm = z.infer<typeof formSchema>;
export const RateLimitPanel = () => {
const { data: rateLimit, isPending } = useGetRateLimit();
const { subscription } = useSubscription();
const { mutateAsync: updateRateLimit } = useUpdateRateLimit();
const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const);
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = useForm<TRateLimitForm>({
resolver: zodResolver(formSchema),
values: {
// eslint-disable-next-line
readRateLimit: rateLimit?.readRateLimit ?? 600,
writeRateLimit: rateLimit?.writeRateLimit ?? 200,
secretsRateLimit: rateLimit?.secretsRateLimit ?? 60,
authRateLimit: rateLimit?.authRateLimit ?? 60,
inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30,
mfaRateLimit: rateLimit?.mfaRateLimit ?? 20,
publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30
}
});
const onRateLimitFormSubmit = async (formData: TRateLimitForm) => {
try {
if (subscription && !subscription.customRateLimits) {
handlePopUpOpen("upgradePlan");
return;
}
const {
readRateLimit,
writeRateLimit,
secretsRateLimit,
authRateLimit,
inviteUserRateLimit,
mfaRateLimit,
publicEndpointLimit
} = formData;
await updateRateLimit({
readRateLimit,
writeRateLimit,
secretsRateLimit,
authRateLimit,
inviteUserRateLimit,
mfaRateLimit,
publicEndpointLimit
});
createNotification({
text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update rate limiting setting."
});
}
};
return isPending ? (
<ContentLoader />
) : (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onRateLimitFormSubmit)}
>
<div className="mb-8 flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">Configure rate limits</div>
<Controller
control={control}
name="readRateLimit"
defaultValue={300}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Global read requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="writeRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Global write requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="secretsRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="authRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Auth requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="inviteUserRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User invitation requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="mfaRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Multi factor auth requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="publicEndpointLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret sharing requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting || !isDirty}>
Save
</Button>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can configure custom rate limits if you switch to Infisical's Enterprise plan."
/>
</form>
);
};
@@ -15,13 +15,23 @@ import {
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms";
import {
AllowedEncryptionKeyAlgorithms,
AsymmetricKeyAlgorithm,
KmsKeyUsage,
SymmetricKeyAlgorithm,
TCmek,
useCreateCmek,
useUpdateCmek
} from "@app/hooks/api/cmeks";
import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().max(500).optional(),
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms),
keyUsage: z.nativeEnum(KmsKeyUsage)
});
export type FormData = z.infer<typeof formSchema>;
@@ -47,24 +57,33 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
control,
handleSubmit,
register,
setValue,
watch,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: cmek?.name,
description: cmek?.description,
encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256
encryptionAlgorithm: SymmetricKeyAlgorithm.AES_GCM_256,
keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT
}
});
const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => {
const handleCreateCmek = async ({
encryptionAlgorithm,
name,
description,
keyUsage
}: FormData) => {
const mutation = isUpdate
? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description })
: createCmek.mutateAsync({
projectId,
encryptionAlgorithm,
name,
description
description,
keyUsage,
encryptionAlgorithm: encryptionAlgorithm as AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm
});
try {
@@ -83,6 +102,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
}
};
const selectedKeyUsage = watch("keyUsage");
return (
<form onSubmit={handleSubmit(handleCreateCmek)}>
<FormControl
@@ -93,23 +114,97 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
>
<Input autoFocus placeholder="my-secret-key" {...register("name")} />
</FormControl>
{!isUpdate && (
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Algorithm" errorText={error?.message} isError={Boolean(error)}>
<Select defaultValue={field.value} onValueChange={onChange} className="w-full">
{Object.entries(EncryptionAlgorithm)?.map(([key, value]) => (
<SelectItem value={value} key={`source-environment-${key}`}>
{key.replaceAll("_", "-")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<div className="flex w-full items-center gap-2">
{!isUpdate && (
<>
<Controller
control={control}
name="keyUsage"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
tooltipText={
<div className="space-y-4">
{Object.entries(KmsKeyUsage).map(([key, value]) => (
<div key={`key-usage-${key}`}>
<p className="font-bold">{kmsKeyUsageOptions[value].label}</p>
<p>{kmsKeyUsageOptions[value].tooltip}</p>
</div>
))}
</div>
}
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
onValueChange={(e) => {
if (keyUsageDefaultOption[e as KmsKeyUsage]) {
setValue("encryptionAlgorithm", keyUsageDefaultOption[e as KmsKeyUsage], {
shouldDirty: true,
shouldValidate: true
});
}
onChange(e);
}}
className="w-full"
>
{Object.entries(KmsKeyUsage)?.map(([key, value]) => (
<SelectItem value={value} key={`key-usage-${key}`}>
{kmsKeyUsageOptions[value].label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
value={field.value}
onValueChange={onChange}
className="w-full"
>
{Object.entries(AllowedEncryptionKeyAlgorithms)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
?.filter(([_, value]) => {
if (selectedKeyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) {
return Object.values(SymmetricKeyAlgorithm).includes(
value as unknown as SymmetricKeyAlgorithm
);
}
if (selectedKeyUsage === KmsKeyUsage.SIGN_VERIFY) {
return Object.values(AsymmetricKeyAlgorithm).includes(
value as unknown as AsymmetricKeyAlgorithm
);
}
return false;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([_, value]) => (
<SelectItem value={value} key={`encryption-algorithm-${value}`}>
<span className="uppercase">{value.replaceAll("-", " ")}</span>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
)}
</div>
<FormControl
label="Description (optional)"
errorText={errors.description?.message}
@@ -0,0 +1,195 @@
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle, faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { SigningAlgorithm, TCmek, useCmekSign } from "@app/hooks/api/cmeks";
const formSchema = z.object({
data: z.string(),
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const SignForm = ({ cmek }: FormProps) => {
const cmekSign = useCmekSign();
const {
handleSubmit,
register,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
? SigningAlgorithm.RSASSA_PSS_SHA_512
: SigningAlgorithm.ECDSA_SHA_256,
isBase64Encoded: false
}
});
const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset<string>({
initialState: "Copy to Clipboard"
});
const handleSignData = async (formData: FormData) => {
try {
await cmekSign.mutateAsync({ ...formData, keyId: cmek.id });
createNotification({
text: "Successfully signed data",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to sign data",
type: "error"
});
}
};
const signature = cmekSign.data?.signature;
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(signature ?? "");
setCopySignature("Copied to Clipboard");
};
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
cmek?.encryptionAlgorithm?.startsWith("RSA")
? a.toLowerCase().startsWith("rsa")
: a.toLowerCase().startsWith("ecdsa")
);
return (
<form onSubmit={handleSubmit(handleSignData)}>
{signature ? (
<FormControl label="Data Signature">
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
isDisabled
value={signature}
/>
</FormControl>
) : (
<>
<FormControl
label="Data to Sign"
errorText={errors.data?.message}
isError={Boolean(errors.data)}
>
<TextArea
{...register("data")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<div className="mb-6 flex w-full items-center justify-between gap-2">
<Controller
control={control}
name="signingAlgorithm"
render={({ field: { onChange, value } }) => (
<FormControl label="Signing Algorithm">
<Select onValueChange={onChange} value={value} className="w-full">
{allowedSigningAlgorithms.map((a) => (
<SelectItem key={a} value={a}>
{a.replaceAll("_", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</div>
</>
)}
<div className="flex items-center">
<Button
className={`mr-4 ${signature ? "w-44" : ""}`}
size="sm"
leftIcon={
// eslint-disable-next-line no-nested-ternary
signature ? (
isCopyingSignature ? (
<FontAwesomeIcon icon={faCheckCircle} />
) : (
<FontAwesomeIcon icon={faFileSignature} />
)
) : (
<FontAwesomeIcon icon={faFileSignature} />
)
}
onClick={signature ? handleCopyToClipboard : undefined}
type={signature ? "button" : "submit"}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{signature ? copySignature : "Sign"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
{signature ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekSignModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Sign Data"
subTitle={
<>
Sign data using <span className="font-bold">{cmek?.name}</span>. Returns a Base64
encoded signature.
</>
}
>
<SignForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -8,6 +8,7 @@ import {
faCopy,
faEdit,
faEllipsis,
faFileSignature,
faInfoCircle,
faKey,
faLock,
@@ -51,14 +52,17 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { kmsKeyUsageOptions } from "@app/helpers/kms";
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, TCmek } from "@app/hooks/api/cmeks/types";
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { CmekDecryptModal } from "./CmekDecryptModal";
import { CmekEncryptModal } from "./CmekEncryptModal";
import { CmekModal } from "./CmekModal";
import { CmekSignModal } from "./CmekSignModal";
import { CmekVerifyModal } from "./CmekVerifyModal";
import { DeleteCmekModal } from "./DeleteCmekModal";
const getStatusBadgeProps = (
@@ -123,7 +127,9 @@ export const CmekTable = () => {
"upsertKey",
"deleteKey",
"encryptData",
"decryptData"
"decryptData",
"signData",
"verifyData"
] as const);
const handleSort = () => {
@@ -179,6 +185,15 @@ export const CmekTable = () => {
ProjectPermissionSub.Cmek
);
const cannotSignData = permission.cannot(
ProjectPermissionCmekActions.Sign,
ProjectPermissionSub.Cmek
);
const cannotVerifyData = permission.cannot(
ProjectPermissionCmekActions.Verify,
ProjectPermissionSub.Cmek
);
return (
<motion.div
key="kms-keys-tab"
@@ -246,6 +261,7 @@ export const CmekTable = () => {
</div>
</Th>
<Th>Key ID</Th>
<Th>Key Usage</Th>
<Th>Algorithm</Th>
<Th>Status</Th>
<Th>Version</Th>
@@ -257,7 +273,15 @@ export const CmekTable = () => {
{!isPending &&
keys.length > 0 &&
keys.map((cmek) => {
const { name, id, version, description, encryptionAlgorithm, isDisabled } = cmek;
const {
name,
id,
version,
description,
encryptionAlgorithm,
isDisabled,
keyUsage
} = cmek;
const { variant, label } = getStatusBadgeProps(isDisabled);
return (
@@ -295,6 +319,14 @@ export const CmekTable = () => {
</IconButton>
</div>
</Td>
<Td>
<div className="flex items-center gap-2">
{kmsKeyUsageOptions[keyUsage].label}
<Tooltip content={kmsKeyUsageOptions[keyUsage].tooltip}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td className="uppercase">{encryptionAlgorithm}</Td>
<Td>
<Badge variant={variant}>{label}</Badge>
@@ -314,50 +346,104 @@ export const CmekTable = () => {
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[160px]">
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
{keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
{keyUsage === KmsKeyUsage.SIGN_VERIFY && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotSignData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("signData", cmek)}
icon={<FontAwesomeIcon icon={faFileSignature} />}
iconPos="left"
isDisabled={cannotSignData || isDisabled}
>
Sign Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotVerifyData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("verifyData", cmek)}
icon={<FontAwesomeIcon icon={faCheckCircle} />}
iconPos="left"
isDisabled={cannotVerifyData || isDisabled}
>
Verify Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
<Tooltip
content={cannotEditKey ? "Access Restricted" : ""}
position="left"
@@ -456,6 +542,16 @@ export const CmekTable = () => {
onOpenChange={(isOpen) => handlePopUpToggle("decryptData", isOpen)}
cmek={popUp.decryptData.data as TCmek}
/>
<CmekSignModal
isOpen={popUp.signData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("signData", isOpen)}
cmek={popUp.signData.data as TCmek}
/>
<CmekVerifyModal
isOpen={popUp.verifyData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("verifyData", isOpen)}
cmek={popUp.verifyData.data as TCmek}
/>
</div>
</motion.div>
);
@@ -0,0 +1,251 @@
import { Controller, useForm } from "react-hook-form";
import { faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { decodeBase64 } from "tweetnacl-util";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { SigningAlgorithm, TCmek, useCmekVerify } from "@app/hooks/api/cmeks";
import { isBase64 } from "@app/lib/fn/base64";
const formSchema = z.object({
data: z.string().min(1, { message: "Data cannot be empty" }),
signature: z
.string()
.min(1, { message: "Signature cannot be empty" })
.superRefine((val, ctx) => {
if (!isBase64(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Signature must be base64-encoded"
});
}
}),
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const VerifyForm = ({ cmek }: FormProps) => {
const cmekVerify = useCmekVerify();
const {
handleSubmit,
register,
watch,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
? SigningAlgorithm.RSASSA_PSS_SHA_512
: SigningAlgorithm.ECDSA_SHA_256,
isBase64Encoded: false
}
});
const handleVerifyData = async (formData: FormData) => {
try {
const result = await cmekVerify.mutateAsync({ ...formData, keyId: cmek.id });
if (result.signatureValid) {
createNotification({
text: "Successfully verified signature",
type: "success"
});
} else {
createNotification({
title: "Signature Verification Failed",
text: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with.",
type: "error"
});
}
} catch (err) {
console.error(err);
createNotification({
text: "Failed to sign data",
type: "error"
});
}
};
const signature = watch("signature");
const data = watch("data");
const isBase64Encoded = watch("isBase64Encoded");
const signatureValid = cmekVerify.data?.signatureValid;
const signingAlgorithm = cmekVerify.data?.signingAlgorithm;
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
cmek?.encryptionAlgorithm?.startsWith("RSA")
? a.toLowerCase().startsWith("rsa")
: a.toLowerCase().startsWith("ecdsa")
);
return (
<form onSubmit={handleSubmit(handleVerifyData)}>
{signatureValid !== undefined ? (
<div className="mb-6 flex flex-col gap-2">
<div className="flex items-center justify-between space-x-2">
<span className="text-sm opacity-60">Signature Status:</span>
<Badge variant={signatureValid ? "success" : "danger"}>
<Tooltip
content={
signatureValid
? "The signature is valid. signature was created using the same signing algorithm and key as the one used to sign the data."
: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with."
}
>
{signatureValid ? (
<div className="flex items-center justify-center gap-2">
<p>Valid</p>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<p>Invalid</p>
</div>
)}
</Tooltip>
</Badge>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-sm opacity-60">Signing Algorithm:</span>
<Badge variant="primary">{signingAlgorithm}</Badge>
</div>
<div className="mt-3">
<span className="text-sm opacity-60">Signature:</span>{" "}
<div className="whitespace-pre-wrap break-words rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
{signature}
</div>
</div>
<div>
<span className="text-sm opacity-60">Data:</span>{" "}
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
{isBase64Encoded ? decodeBase64(data).toString() : data}
</div>
</div>
</div>
) : (
<>
<FormControl
label="Data to Verify"
errorText={errors.data?.message}
isError={Boolean(errors.data)}
>
<TextArea
{...register("data")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<FormControl
label="Signature of Data"
tooltipText="Must be base64-encoded, like the signature you received when you signed the data."
errorText={errors.signature?.message}
isError={Boolean(errors.signature)}
>
<TextArea
{...register("signature")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<div className="mb-6 flex w-full items-center justify-between gap-2">
<Controller
control={control}
name="signingAlgorithm"
render={({ field: { onChange, value } }) => (
<FormControl label="Signing Algorithm">
<Select onValueChange={onChange} value={value} className="w-full">
{allowedSigningAlgorithms.map((a) => (
<SelectItem key={a} value={a}>
{a.replaceAll("_", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</div>
</>
)}
<div className="flex items-center">
{signatureValid === undefined && (
<Button
className="mr-4 w-44"
size="sm"
leftIcon={<FontAwesomeIcon icon={faFileSignature} />}
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Verify
</Button>
)}
<ModalClose asChild>
<Button
colorSchema={signatureValid === undefined ? "secondary" : "primary"}
variant={signatureValid === undefined ? "plain" : undefined}
>
{signatureValid !== undefined ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekVerifyModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Verify Signature"
subTitle={
<>
Verify a signature using <span className="font-bold">{cmek?.name}</span>.
</>
}
>
<VerifyForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -1,7 +1,10 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faFilter,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
@@ -12,14 +15,19 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
FormControl,
IconButton,
Input,
Pagination,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
@@ -30,11 +38,12 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -68,22 +77,22 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { data, isPending, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit,
orderDirection,
orderBy,
search: debouncedSearch
},
{ placeholderData: (prevData) => prevData }
);
const { data, isPending, isFetching } = useSearchIdentities({
offset,
limit,
orderDirection,
orderBy,
search: {
name: debouncedSearch ? { $contains: debouncedSearch } : undefined,
role: filteredRoles?.length ? { $in: filteredRoles } : undefined
}
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
@@ -91,6 +100,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
offset,
setPage
});
const filterForm = useForm<{ roles: string }>();
const { data: roles } = useGetOrgRoles(organizationId);
@@ -132,13 +142,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
return (
<div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div className="mb-4 flex items-center space-x-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div>
<Popover>
<PopoverTrigger>
<IconButton
ariaLabel="filter"
variant="outline_bg"
className={filteredRoles?.length ? "border-primary" : ""}
>
<Tooltip content="Advance Filter">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl">
<div className="mb-4 border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Advance Filter
</div>
<form
onSubmit={filterForm.handleSubmit((el) => {
setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []);
})}
>
<Controller
control={filterForm.control}
name="roles"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Roles"
helperText="Eg: admin,viewer"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
type="submit"
size="xs"
colorSchema="primary"
variant="outline_bg"
className="mt-4"
>
Apply Filter
</Button>
{Boolean(filteredRoles.length) && (
<Button
size="xs"
variant="link"
className="ml-4 mt-4"
onClick={() => {
filterForm.reset({ roles: "" });
setFilteredRoles([]);
}}
>
Clear
</Button>
)}
</div>
</form>
</PopoverContent>
</Popover>
</div>
</div>
<TableContainer>
<Table>
<THead>
@@ -190,7 +265,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-identities" />}
{!isPending &&
data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => {
data?.identities?.map(({ identity: { id, name }, role, customRole }) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
@@ -307,10 +382,10 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
{!isPending && data && data?.identities.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
debouncedSearch.trim().length > 0 || filteredRoles?.length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}
@@ -258,7 +258,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Email
Username
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Email ? "" : "opacity-30"}`}
@@ -478,7 +478,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
if (currentOrg?.scimEnabled && isActive) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
@@ -12,12 +12,15 @@ import { AppConnectionHeader } from "../AppConnectionHeader";
import { AwsConnectionForm } from "./AwsConnectionForm";
import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm";
import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm";
import { CamundaConnectionForm } from "./CamundaConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
import { VercelConnectionForm } from "./VercelConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;
@@ -70,10 +73,16 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} />;
case AppConnection.TerraformCloud:
return <TerraformCloudConnectionForm onSubmit={onSubmit} />;
case AppConnection.Vercel:
return <VercelConnectionForm onSubmit={onSubmit} />;
case AppConnection.Postgres:
return <PostgresConnectionForm onSubmit={onSubmit} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} />;
case AppConnection.Camunda:
return <CamundaConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -124,10 +133,16 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.TerraformCloud:
return <TerraformCloudConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Vercel:
return <VercelConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Postgres:
return <PostgresConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Camunda:
return <CamundaConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,148 @@
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 { CamundaConnectionMethod, TCamundaConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TCamundaConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Camunda)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(CamundaConnectionMethod.ClientCredentials),
credentials: z.object({
clientId: z.string().trim().min(1, "Client ID required"),
clientSecret: z.string().trim().min(1, "Client secret required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const CamundaConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Camunda,
method: CamundaConnectionMethod.ClientCredentials
}
});
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.Camunda].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(CamundaConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.clientId"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Client ID"
>
<Input {...field} placeholder="YrBDnQYLevSe40PJ" />
</FormControl>
)}
/>
<Controller
name="credentials.clientSecret"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Client Secret"
>
<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 Camunda"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,135 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import {
TerraformCloudConnectionMethod,
TTerraformCloudConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TTerraformCloudConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.TerraformCloud)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(TerraformCloudConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "API Token required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const TerraformCloudConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.TerraformCloud,
method: TerraformCloudConnectionMethod.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.TerraformCloud].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(TerraformCloudConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</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 Terraform Cloud"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,135 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
TVercelConnection,
VercelConnectionMethod
} from "@app/hooks/api/appConnections/types/vercel-connection";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TVercelConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Vercel)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(VercelConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "Service API Token required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const VercelConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Vercel,
method: VercelConnectionMethod.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.Vercel].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(VercelConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</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 Vercel"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -24,7 +24,7 @@ const formatDescription = (type: ProjectType) => {
return "Manage your PKI infrastructure and issue digital certificates for services, applications, and devices.";
if (type === ProjectType.KMS)
return "Centralize the management of keys for cryptographic operations, such as encryption and decryption.";
return "Generate SSH credentials to provide secure and centralized SSH access control for your infrastructure.";
return "Infisical SSH lets you issue SSH credentials to users for short-lived, secure SSH access to infrastructure.";
};
type Props = {
@@ -115,8 +115,29 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
});
} else {
const inviteeEmails = selectedMembers
.map((member) => member?.user.username as string)
.filter(Boolean);
.map((member) => {
if (!member) return null;
if (member.user.email) {
return member.user.email;
}
if (member.user.username) {
return member.user.username;
}
return null;
})
.filter(Boolean) as string[];
if (inviteeEmails.length !== selectedMembers.length) {
createNotification({
text: "Failed to add users to project. One or more users were invalid.",
type: "error"
});
return;
}
if (inviteeEmails.length || newInvitees.length) {
await addMembersToProject({
inviteeEmails: [...inviteeEmails, ...newInvitees],
@@ -67,7 +67,8 @@ const secretPermissionSchema = z.object({
z.object({
isTemporary: z.literal(false)
})
])
]),
note: z.string().optional()
});
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
export const SpecificPrivilegeSecretForm = ({
@@ -231,7 +232,8 @@ export const SpecificPrivilegeSecretForm = ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
}))
})),
note: data.note
});
createNotification({
@@ -541,6 +543,18 @@ export const SpecificPrivilegeSecretForm = ({
)}
</div>
</div>
<div className="mb-4 flex w-full">
<Controller
control={privilegeForm.control}
name="note"
render={({ field }) => (
<div className="w-full">
<FormLabel label="Note" className="mb-2" />
<Input {...field} isDisabled={isMemberEditDisabled} maxLength={255} />
</div>
)}
/>
</div>
{!!policies && (
<Button
type="submit"
@@ -86,7 +86,20 @@ const Page = () => {
title={identityMembershipDetails?.identity?.name}
description={`Identity joined on ${identityMembershipDetails?.createdAt && formatRelative(new Date(identityMembershipDetails?.createdAt || ""), new Date())}`}
>
<div>
<div className="flex items-center gap-2">
<Button
variant="outline_bg"
size="xs"
onClick={() => {
navigator.clipboard.writeText(identityMembershipDetails.id);
createNotification({
text: "Membership ID copied to clipboard",
type: "success"
});
}}
>
Copy Membership ID
</Button>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
@@ -19,6 +19,7 @@ import {
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretSyncActions,
ProjectPermissionSshHostActions,
TPermissionCondition,
TPermissionConditionOperators
} from "@app/context/ProjectPermissionContext/types";
@@ -46,7 +47,9 @@ const CmekPolicyActionSchema = z.object({
delete: z.boolean().optional(),
create: z.boolean().optional(),
encrypt: z.boolean().optional(),
decrypt: z.boolean().optional()
decrypt: z.boolean().optional(),
sign: z.boolean().optional(),
verify: z.boolean().optional()
});
const DynamicSecretPolicyActionSchema = z.object({
@@ -108,6 +111,14 @@ const GroupPolicyActionSchema = z.object({
[ProjectPermissionGroupActions.GrantPrivileges]: z.boolean().optional()
});
const SshHostPolicyActionSchema = z.object({
[ProjectPermissionSshHostActions.Read]: z.boolean().optional(),
[ProjectPermissionSshHostActions.Create]: z.boolean().optional(),
[ProjectPermissionSshHostActions.Edit]: z.boolean().optional(),
[ProjectPermissionSshHostActions.Delete]: z.boolean().optional(),
[ProjectPermissionSshHostActions.IssueHostCert]: z.boolean().optional()
});
const SecretRollbackPolicyActionSchema = z.object({
read: z.boolean().optional(),
create: z.boolean().optional()
@@ -215,6 +226,12 @@ export const projectRoleFormSchema = z.object({
),
[ProjectPermissionSub.SshCertificates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshCertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshHosts]: SshHostPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
@@ -241,6 +258,7 @@ type TConditionalFields =
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
@@ -251,8 +269,9 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.DynamicSecrets ||
subject === ProjectPermissionSub.SecretImports ||
subject === ProjectPermissionSub.SecretFolders ||
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.Identity;
subject === ProjectPermissionSub.Identity ||
subject === ProjectPermissionSub.SshHosts ||
subject === ProjectPermissionSub.SecretRotation;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = [];
@@ -308,7 +327,10 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Tags,
ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Kms
ProjectPermissionSub.Kms,
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates
].includes(subject)
) {
// from above statement we are sure it won't be undefined
@@ -462,6 +484,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
const canSign = action.includes(ProjectPermissionCmekActions.Sign);
const canVerify = action.includes(ProjectPermissionCmekActions.Verify);
if (!formVal[subject]) formVal[subject] = [{}];
@@ -472,6 +496,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0].delete = true;
if (canEncrypt) formVal[subject]![0].encrypt = true;
if (canDecrypt) formVal[subject]![0].decrypt = true;
if (canSign) formVal[subject]![0].sign = true;
if (canVerify) formVal[subject]![0].verify = true;
return;
}
@@ -577,7 +603,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canRemoveSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true;
}
if (subject === ProjectPermissionSub.SshHosts) {
if (!formVal[subject]) formVal[subject] = [];
formVal[subject]!.push({
[ProjectPermissionSshHostActions.Edit]: action.includes(
ProjectPermissionSshHostActions.Edit
),
[ProjectPermissionSshHostActions.Delete]: action.includes(
ProjectPermissionSshHostActions.Delete
),
[ProjectPermissionSshHostActions.Create]: action.includes(
ProjectPermissionSshHostActions.Create
),
[ProjectPermissionSshHostActions.Read]: action.includes(
ProjectPermissionSshHostActions.Read
),
[ProjectPermissionSshHostActions.IssueHostCert]: action.includes(
ProjectPermissionSshHostActions.IssueHostCert
),
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
}
});
return formVal;
};
@@ -724,7 +775,9 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" },
{ label: "Encrypt", value: "encrypt" },
{ label: "Decrypt", value: "decrypt" }
{ label: "Decrypt", value: "decrypt" },
{ label: "Sign", value: "sign" },
{ label: "Verify", value: "verify" }
]
},
[ProjectPermissionSub.Kms]: {
@@ -901,6 +954,16 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshHosts]: {
title: "SSH Hosts",
actions: [
{ label: "Read", value: ProjectPermissionSshHostActions.Read },
{ label: "Create", value: ProjectPermissionSshHostActions.Create },
{ label: "Modify", value: ProjectPermissionSshHostActions.Edit },
{ label: "Remove", value: ProjectPermissionSshHostActions.Delete },
{ label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert }
]
},
[ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections",
actions: [
@@ -19,6 +19,7 @@ import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
@@ -33,6 +34,7 @@ import {
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SecretPermissionConditions } from "./SecretPermissionConditions";
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
type Props = {
roleSlug: string;
@@ -51,6 +53,10 @@ export const renderConditionalComponents = (
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
}
if (subject === ProjectPermissionSub.SshHosts) {
return <SshHostPermissionConditions isDisabled={isDisabled} />;
}
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
}
@@ -134,7 +140,9 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
return (
<div className="w-full">
<AccessTree permissions={formattedPermissions} />
{currentWorkspace.type === ProjectType.SecretManager && (
<AccessTree permissions={formattedPermissions} />
)}
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
@@ -0,0 +1,173 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const SshHostPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
formState: { errors }
} = useFormContext<TFormSchema>();
const permissionSubject = ProjectPermissionSub.SshHosts;
const items = useFieldArray({
control,
name: `permissions.${permissionSubject}.${position}.conditions`
});
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition =
(watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as {
lhs: string;
rhs: string;
operator: string;
}) || {};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value="hostname">Hostname</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value={PermissionConditionOperators.$EQ}>Equals</SelectItem>
<SelectItem value={PermissionConditionOperators.$GLOB}>Glob</SelectItem>
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
</Select>
</FormControl>
)}
/>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "hostname",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,29 @@
import { useCamundaConnectionListClusters } from "@app/hooks/api/appConnections/camunda";
import { TCamundaSync } from "@app/hooks/api/secretSyncs/types/camunda-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TCamundaSync;
};
export const CamundaSyncDestinationCol = ({ secretSync }: Props) => {
const { data: clusters, isPending } = useCamundaConnectionListClusters(secretSync.connectionId);
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
...secretSync,
destinationConfig: {
...secretSync.destinationConfig,
clusterName: clusters?.find(
(cluster) => cluster.uuid === secretSync.destinationConfig.clusterUUID
)?.name
}
});
if (isPending) {
return <SecretSyncTableCell primaryText="Loading cluster info..." secondaryText="Cluster" />;
}
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -4,10 +4,13 @@ import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDest
import { AwsSecretsManagerSyncDestinationCol } from "./AwsSecretsManagerSyncDestinationCol";
import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfigurationDestinationSyncCol";
import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol";
import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol";
type Props = {
secretSync: TSecretSync;
@@ -31,6 +34,12 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <DatabricksSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Humanitec:
return <HumanitecSyncDestinationCol secretSync={secretSync} />;
case SecretSync.TerraformCloud:
return <TerraformCloudSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Camunda:
return <CamundaSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Vercel:
return <VercelSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -0,0 +1,14 @@
import { TTerraformCloudSync } from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TTerraformCloudSync;
};
export const TerraformCloudSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -0,0 +1,14 @@
import { TVercelSync } from "@app/hooks/api/secretSyncs/types/vercel-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TVercelSync;
};
export const VercelSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -1,3 +1,4 @@
import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
@@ -73,6 +74,22 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
}
secondaryText = `Organization - ${destinationConfig.org}`;
break;
case SecretSync.TerraformCloud:
primaryText = destinationConfig.org;
if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) {
secondaryText = destinationConfig.variableSetName;
} else {
secondaryText = destinationConfig.workspaceName;
}
break;
case SecretSync.Camunda:
primaryText = destinationConfig.clusterName ?? destinationConfig.clusterUUID;
secondaryText = "Cluster";
break;
case SecretSync.Vercel:
primaryText = destinationConfig.appName || destinationConfig.app;
secondaryText = destinationConfig.env;
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -64,7 +64,7 @@ export const SecretApprovalsPage = () => {
<Tabs defaultValue={defaultTab}>
<TabList>
<Tab value={TabSection.SecretApprovalRequests}>
Secret Requests
Change Requests
{Boolean(secretApprovalReqCount?.open) && (
<Badge className="ml-2">{secretApprovalReqCount?.open}</Badge>
)}
@@ -136,6 +136,13 @@ export const ReviewAccessRequestModal = ({
<span className="font-bold">Access Type: </span>
<span>{getAccessLabel()}</span>
</div>
{request.note && (
<div className="mt-1">
<span className="font-bold">User Note: </span>
<span>{request.note}</span>
</div>
)}
</div>
<div className="space-x-2">
@@ -0,0 +1,21 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { useCamundaConnectionListClusters } from "@app/hooks/api/appConnections/camunda";
import { TCamundaSync } from "@app/hooks/api/secretSyncs/types/camunda-sync";
type Props = {
secretSync: TCamundaSync;
};
export const CamundaSyncDestinationSection = ({ secretSync }: Props) => {
const { data: clusters, isPending } = useCamundaConnectionListClusters(secretSync.connectionId);
const {
destinationConfig: { clusterUUID }
} = secretSync;
if (isPending) {
return <GenericFieldLabel label="Cluster">Loading...</GenericFieldLabel>;
}
const clusterName = clusters?.find((cluster) => cluster.uuid === clusterUUID)?.name;
return <GenericFieldLabel label="Cluster">{clusterName ?? clusterUUID}</GenericFieldLabel>;
};
@@ -16,8 +16,11 @@ import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSy
import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection";
import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection";
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection";
import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection";
type Props = {
secretSync: TSecretSync;
@@ -57,6 +60,15 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Humanitec:
DestinationComponents = <HumanitecSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.TerraformCloud:
DestinationComponents = <TerraformCloudSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Camunda:
DestinationComponents = <CamundaSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Vercel:
DestinationComponents = <VercelSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
@@ -0,0 +1,65 @@
import { ReactNode } from "react";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TerraformCloudSyncCategory } from "@app/hooks/api/appConnections/terraform-cloud";
import {
TerraformCloudSyncScope,
TTerraformCloudSync
} from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync";
type Props = {
secretSync: TTerraformCloudSync;
};
export const TerraformCloudSyncDestinationSection = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
let Components: ReactNode;
switch (destinationConfig.scope) {
case TerraformCloudSyncScope.VariableSet:
Components = (
<>
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
<GenericFieldLabel label="Variable Set">
{destinationConfig.variableSetName}
</GenericFieldLabel>
<GenericFieldLabel label="Category">
{Object.keys(TerraformCloudSyncCategory).find(
(key) =>
TerraformCloudSyncCategory[key as keyof typeof TerraformCloudSyncCategory] ===
destinationConfig.category
)}
</GenericFieldLabel>
</>
);
break;
case TerraformCloudSyncScope.Workspace:
Components = (
<>
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
<GenericFieldLabel label="Workspace">{destinationConfig.workspaceName}</GenericFieldLabel>
<GenericFieldLabel label="Category">
{Object.keys(TerraformCloudSyncCategory).find(
(key) =>
TerraformCloudSyncCategory[key as keyof typeof TerraformCloudSyncCategory] ===
destinationConfig.category
)}
</GenericFieldLabel>
</>
);
break;
default:
throw new Error(
`Unhandled Terraform Cloud Sync Destination Section Scope ${secretSync.destinationConfig.scope}`
);
}
return (
<>
<GenericFieldLabel className="capitalize" label="Scope">
{destinationConfig.scope.replace("-", " ")}
</GenericFieldLabel>
{Components}
</>
);
};
@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TVercelSync, VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync";
type Props = {
secretSync: TVercelSync;
};
export const VercelSyncDestinationSection = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
let Components: ReactNode;
if (destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch) {
Components = (
<>
<GenericFieldLabel label="Vercel App">
{destinationConfig.appName || destinationConfig.app}
</GenericFieldLabel>
<GenericFieldLabel label="Environment">{destinationConfig.env}</GenericFieldLabel>
<GenericFieldLabel label="Preview Branch">{destinationConfig.branch}</GenericFieldLabel>
</>
);
} else {
Components = (
<>
<GenericFieldLabel label="Vercel App">
{destinationConfig.appName || destinationConfig.app}
</GenericFieldLabel>
<GenericFieldLabel label="Environment">{destinationConfig.env}</GenericFieldLabel>
</>
);
}
return Components;
};
@@ -48,6 +48,9 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.AzureAppConfiguration:
case SecretSync.Databricks:
case SecretSync.Humanitec:
case SecretSync.TerraformCloud:
case SecretSync.Camunda:
case SecretSync.Vercel:
AdditionalSyncOptionsComponent = null;
break;
default:
@@ -1,77 +0,0 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { SshCaSection, SshCertificatesSection } from "./components";
enum TabSections {
SshCa = "ssh-certificate-authorities",
SshCertificates = "ssh-certificates"
}
export const OverviewPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Overview"
description="Infisical SSH lets you issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure."
/>
<Tabs defaultValue={TabSections.SshCertificates}>
<TabList>
<Tab value={TabSections.SshCertificates}>SSH Certificates</Tab>
<Tab value={TabSections.SshCa}>Certificate Authorities</Tab>
</TabList>
<TabPanel value={TabSections.SshCertificates}>
<motion.div
key="panel-ssh-certificate-s"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificates}
renderGuardBanner
passThrough={false}
>
<SshCertificatesSection />
</ProjectPermissionCan>
</motion.div>
</TabPanel>
<TabPanel value={TabSections.SshCa}>
<motion.div
key="panel-ssh-certificate-authorities"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
renderGuardBanner
passThrough={false}
>
<SshCaSection />
</ProjectPermissionCan>
</motion.div>
</TabPanel>
</Tabs>
</div>
</div>
</div>
</>
);
};
@@ -1,198 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshCa"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCa"]>, state?: boolean) => void;
};
const schema = z
.object({
friendlyName: z.string(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
])
})
.required();
export type FormData = z.infer<typeof schema>;
export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: ca } = useGetSshCaById((popUp?.sshCa?.data as { caId: string })?.caId || "");
const { mutateAsync: createMutateAsync } = useCreateSshCa();
const { mutateAsync: updateMutateAsync } = useUpdateSshCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
friendlyName: "",
keyAlgorithm: CertKeyAlgorithm.RSA_2048
}
});
useEffect(() => {
if (ca) {
reset({
friendlyName: ca.friendlyName,
keyAlgorithm: ca.keyAlgorithm
});
} else {
reset({
friendlyName: "",
keyAlgorithm: CertKeyAlgorithm.RSA_2048
});
}
}, [ca]);
const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => {
try {
if (!projectId) return;
if (ca) {
await updateMutateAsync({
caId: ca.id,
friendlyName
});
} else {
const { id: newCaId } = await createMutateAsync({
projectId,
friendlyName,
keyAlgorithm
});
navigate({
to: `/${ProjectType.SSH}/$projectId/ca/$caId` as const,
params: {
projectId,
caId: newCaId
}
});
}
reset();
handlePopUpToggle("sshCa", false);
createNotification({
text: `Successfully ${ca ? "updated" : "created"} SSH CA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create SSH CA",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshCa?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("sshCa", isOpen);
}}
>
<ModalContent title={`${ca ? "View" : "Create"} SSH CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{ca && (
<FormControl label="CA ID">
<Input value={ca.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="My SSH CA" />
</FormControl>
)}
/>
<Controller
control={control}
name="keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{certKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.sshCa?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshCa", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -20,7 +20,7 @@ import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshCaModal } from "../OverviewPage/components/SshCaModal";
import { SshCaModal } from "../SshCasPage/components/SshCaModal";
import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components";
const Page = () => {
@@ -8,7 +8,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetSshCaById } from "@app/hooks/api";
import { caStatusToNameMap } from "@app/hooks/api/ca/constants";
import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
import { sshCertKeyAlgorithmToNameMap } from "@app/hooks/api/sshCa/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -93,7 +93,9 @@ export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Key Algorithm</p>
<p className="text-sm text-mineshaft-300">{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}</p>
<p className="text-sm text-mineshaft-300">
{sshCertKeyAlgorithmToNameMap[ca.keyAlgorithm]}
</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Public Key</p>
@@ -22,9 +22,11 @@ import {
useListWorkspaceSshCertificateTemplates,
useSignSshKey
} from "@app/hooks/api";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { SshCertType } from "@app/hooks/api/sshCa/constants";
import {
SshCertKeyAlgorithm,
sshCertKeyAlgorithms,
SshCertType
} from "@app/hooks/api/sshCa/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { SshCertificateContent } from "./SshCertificateContent";
@@ -33,10 +35,11 @@ const schema = z.object({
templateId: z.string(),
publicKey: z.string().optional(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
SshCertKeyAlgorithm.RSA_2048,
SshCertKeyAlgorithm.RSA_4096,
SshCertKeyAlgorithm.ECDSA_P256,
SshCertKeyAlgorithm.ECDSA_P384,
SshCertKeyAlgorithm.ED25519
]),
certType: z.nativeEnum(SshCertType),
principals: z.string(),
@@ -95,7 +98,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
keyAlgorithm: CertKeyAlgorithm.RSA_2048,
keyAlgorithm: SshCertKeyAlgorithm.ED25519,
certType: SshCertType.USER
}
});
@@ -282,7 +285,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
name="keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
defaultValue={SshCertKeyAlgorithm.ED25519}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
@@ -295,7 +298,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
onValueChange={(e) => onChange(e)}
className="w-full"
>
{certKeyAlgorithms.map(({ label, value }) => (
{sshCertKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
@@ -60,8 +60,8 @@ const schema = z
allowHostCertificates: z.boolean().optional().default(false),
allowCustomKeyIds: z.boolean().optional().default(false)
})
.refine((data) => ms(data.maxTTL) > ms(data.ttl), {
message: "Max TLL must be greater than TTL",
.refine((data) => ms(data.maxTTL) >= ms(data.ttl), {
message: "Max TLL must be greater than or equal to TTL",
path: ["maxTTL"]
});
@@ -0,0 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { SshCaSection } from "./components";
export const SshCasPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "SSH" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="SSH Certificate Authorities"
description="Manage the SSH certificate authorities used to sign user and host certificates, including custom and default CAs."
/>
<SshCaSection />
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,297 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api";
import {
SshCaKeySource,
SshCertKeyAlgorithm,
sshCertKeyAlgorithms
} from "@app/hooks/api/sshCa/constants";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshCa"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCa"]>, state?: boolean) => void;
};
const sshCaKeySources = [
{ label: "Internal", value: SshCaKeySource.INTERNAL },
{ label: "External", value: SshCaKeySource.EXTERNAL }
];
const schema = z
.object({
friendlyName: z.string(),
keySource: z.nativeEnum(SshCaKeySource),
publicKey: z.string().optional(),
privateKey: z.string().optional(),
keyAlgorithm: z.enum([
SshCertKeyAlgorithm.RSA_2048,
SshCertKeyAlgorithm.RSA_4096,
SshCertKeyAlgorithm.ECDSA_P256,
SshCertKeyAlgorithm.ECDSA_P384,
SshCertKeyAlgorithm.ED25519
])
})
.required();
export type FormData = z.infer<typeof schema>;
export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: ca } = useGetSshCaById((popUp?.sshCa?.data as { caId: string })?.caId || "");
const { mutateAsync: createMutateAsync } = useCreateSshCa();
const { mutateAsync: updateMutateAsync } = useUpdateSshCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
friendlyName: "",
keyAlgorithm: SshCertKeyAlgorithm.ED25519,
keySource: SshCaKeySource.INTERNAL,
publicKey: "",
privateKey: ""
}
});
const caKeySource = watch("keySource");
useEffect(() => {
if (ca) {
reset({
friendlyName: ca.friendlyName,
keyAlgorithm: ca.keyAlgorithm,
keySource: ca.keySource,
publicKey: ca.publicKey
});
} else {
reset({
friendlyName: "",
keyAlgorithm: SshCertKeyAlgorithm.ED25519,
keySource: SshCaKeySource.INTERNAL,
publicKey: "",
privateKey: ""
});
}
}, [ca]);
const onFormSubmit = async ({
friendlyName,
keySource,
keyAlgorithm,
publicKey,
privateKey
}: FormData) => {
try {
if (!projectId) return;
if (ca) {
await updateMutateAsync({
caId: ca.id,
friendlyName
});
} else {
const { id: newCaId } = await createMutateAsync({
projectId,
friendlyName,
keySource,
keyAlgorithm,
publicKey,
privateKey
});
navigate({
to: `/${ProjectType.SSH}/$projectId/ca/$caId` as const,
params: {
projectId,
caId: newCaId
}
});
}
reset();
handlePopUpToggle("sshCa", false);
createNotification({
text: `Successfully ${ca ? "updated" : "created"} SSH CA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${ca ? "update" : "create"} SSH CA`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshCa?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("sshCa", isOpen);
}}
>
<ModalContent title={`${ca ? "View" : "Create"} SSH CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{ca && (
<FormControl label="CA ID">
<Input value={ca.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="My SSH CA" />
</FormControl>
)}
/>
{caKeySource && (
<Controller
control={control}
name="keySource"
defaultValue={SshCaKeySource.INTERNAL}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Source"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{sshCaKeySources.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
{caKeySource === SshCaKeySource.INTERNAL && (
<Controller
control={control}
name="keyAlgorithm"
defaultValue={SshCertKeyAlgorithm.ED25519}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
errorText={error?.message}
isError={Boolean(error)}
isRequired={caKeySource === SshCaKeySource.INTERNAL}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{sshCertKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
{caKeySource === SshCaKeySource.EXTERNAL && !ca && (
<>
<Controller
control={control}
defaultValue=""
name="publicKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Public Key"
isError={Boolean(error)}
errorText={error?.message}
isRequired={caKeySource === SshCaKeySource.EXTERNAL}
>
<Input {...field} placeholder="ssh-rsa AAA..." />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Private Key"
errorText={error?.message}
isError={Boolean(error)}
isRequired={caKeySource === SshCaKeySource.EXTERNAL}
>
<TextArea {...field} placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ..." />
</FormControl>
)}
/>
</>
)}
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.sshCa?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshCa", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { SshCaSection } from "./SshCaSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { SshCasPage } from "./SshCasPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/cas"
)({
component: SshCasPage
});
@@ -0,0 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { SshCertificatesSection } from "./components";
export const SshCertsPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="SSH Certificates"
description="View and audit all issued SSH certificates, including validity and associated access metadata."
/>
<SshCertificatesSection />
</div>
</div>
</div>
</>
);
};
@@ -10,7 +10,7 @@ import { SshCertificateModal } from "../../SshCaByIDPage/components/SshCertifica
import { SshCertificatesTable } from "./SshCertificatesTable";
export const SshCertificatesSection = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["sshCertificate"] as const);
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["sshCertificate"] as const);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
@@ -57,12 +57,12 @@ export const SshCertificatesTable = () => {
</Td>
<Td>
{certificate.notBefore
? format(new Date(certificate.notBefore), "yyyy-MM-dd")
? format(new Date(certificate.notBefore), "yyyy-MM-dd | HH:mm:ss")
: "-"}
</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
? format(new Date(certificate.notAfter), "yyyy-MM-dd | HH:mm:ss")
: "-"}
</Td>
</Tr>
@@ -1,2 +1 @@
export { SshCaSection } from "./SshCaSection";
export { SshCertificatesSection } from "./SshCertificatesSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { SshCertsPage } from "./SshCertsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/certificates"
)({
component: SshCertsPage
});
@@ -0,0 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { SshHostsSection } from "./components";
export const SshHostsPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "SSH" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Hosts"
description="Manage your SSH hosts, configure access policies, and define login behavior for secure connections."
/>
<SshHostsSection />
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,411 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateSshHost,
useGetSshHostById,
useGetWorkspaceUsers,
useUpdateSshHost
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshHost"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshHost"]>, state?: boolean) => void;
};
const schema = z
.object({
hostname: z.string(),
userCertTtl: z
.string()
.trim()
.refine(
(val) => ms(val) > 0,
"TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..."
)
.default("8h"),
loginMappings: z
.object({
loginUser: z.string().trim().min(1),
allowedPrincipals: z.array(z.string().trim()).default([])
})
.array()
.default([])
})
.required();
export type FormData = z.infer<typeof schema>;
export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: members = [] } = useGetWorkspaceUsers(projectId);
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
const { data: sshHost } = useGetSshHostById(
(popUp?.sshHost?.data as { sshHostId: string })?.sshHostId || ""
);
const { mutateAsync: createMutateAsync } = useCreateSshHost();
const { mutateAsync: updateMutateAsync } = useUpdateSshHost();
const {
control,
handleSubmit,
reset,
getValues,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
hostname: "",
userCertTtl: "8h",
loginMappings: []
}
});
const loginMappingsFormFields = useFieldArray({
control,
name: "loginMappings"
});
useEffect(() => {
if (sshHost) {
reset({
hostname: sshHost.hostname,
userCertTtl: sshHost.userCertTtl,
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: allowedPrincipals.usernames
}))
});
setExpandedMappings(
Object.fromEntries(sshHost.loginMappings.map((_, index) => [index, false]))
);
} else {
reset({
hostname: "",
userCertTtl: "8h",
loginMappings: []
});
}
}, [sshHost]);
const onFormSubmit = async ({ hostname, userCertTtl, loginMappings }: FormData) => {
try {
if (!projectId) return;
if (sshHost) {
await updateMutateAsync({
sshHostId: sshHost.id,
hostname,
userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
});
} else {
await createMutateAsync({
projectId,
hostname,
userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
});
}
reset();
handlePopUpToggle("sshHost", false);
createNotification({
text: `Successfully ${sshHost ? "updated" : "added"} SSH host`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${sshHost ? "update" : "add"} SSH host`,
type: "error"
});
}
};
const toggleMapping = (index: number) => {
setExpandedMappings((prev) => ({
...prev,
[index]: !prev[index]
}));
};
return (
<Modal
isOpen={popUp?.sshHost?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("sshHost", isOpen);
}}
>
<ModalContent title={`${sshHost ? "View" : "Add"} SSH host`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{sshHost && (
<FormControl label="SSH Host ID">
<Input value={sshHost.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="hostname"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Hostname"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="host.example.com" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="userCertTtl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User Certificate TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="8h, 1d, 30m" />
</FormControl>
)}
/>
<div className="mb-4 flex items-center justify-between">
<FormLabel label="Login Mappings" />
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
setExpandedMappings((prev) => ({
...prev,
[newIndex]: true
}));
}}
>
Add Login Mapping
</Button>
</div>
<div className="mb-4 flex flex-col space-y-4">
{loginMappingsFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div
key={metadataFieldId}
className="flex flex-col space-y-2 rounded-md border border-mineshaft-600 p-4"
>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
className="flex cursor-pointer items-center py-1 text-sm text-mineshaft-200"
onClick={() => toggleMapping(i)}
>
<FontAwesomeIcon
icon={expandedMappings[i] ? faChevronDown : faChevronRight}
className="mr-4"
size="sm"
/>
<Controller
control={control}
name={`loginMappings.${i}.loginUser`}
render={({ field }) => (
<span className="text-sm font-medium leading-tight">
{field.value || "New Login Mapping"}
</span>
)}
/>
</button>
<IconButton
ariaLabel="delete login mapping"
variant="plain"
onClick={() => loginMappingsFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
{expandedMappings[i] && (
<>
<div>
<span className="text-xs text-mineshaft-400">Login User</span>
<Controller
control={control}
name={`loginMappings.${i}.loginUser`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input
{...field}
placeholder="ec2-user"
onChange={(e) => {
const newValue = e.target.value;
const loginMappings = getValues("loginMappings");
const isDuplicate = loginMappings.some(
(mapping, index) => index !== i && mapping.loginUser === newValue
);
if (isDuplicate) {
createNotification({
text: "This login user already exists",
type: "error"
});
return;
}
field.onChange(e);
}}
/>
</FormControl>
)}
/>
</div>
<div className="flex flex-col space-y-2">
<div className="mb-2 mt-4 flex items-center justify-between">
<FormLabel
label="Allowed Principals"
className="text-xs text-mineshaft-400"
/>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
</div>
<Controller
control={control}
name={`loginMappings.${i}.allowedPrincipals`}
render={({ field: { value = [], onChange }, fieldState: { error } }) => (
<div className="flex flex-col space-y-2">
{(value.length === 0 ? [""] : value).map(
(principal: string, principalIndex: number) => (
<div
key={`${metadataFieldId}-principal-${principal}`}
className="flex items-center space-x-2"
>
<div className="flex-1">
<Select
value={principal}
onValueChange={(newValue) => {
if (value.includes(newValue)) {
createNotification({
text: "This principal is already added",
type: "error"
});
return;
}
const newPrincipals = [...value];
newPrincipals[principalIndex] = newValue;
onChange(newPrincipals);
}}
placeholder="Select a member"
className="w-full"
>
{members.map((member) => (
<SelectItem
key={member.user.id}
value={member.user.username}
>
{member.user.username}
</SelectItem>
))}
</Select>
</div>
<IconButton
size="sm"
ariaLabel="delete principal"
variant="plain"
className="h-9"
onClick={() => {
const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex
);
onChange(newPrincipals);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
)
)}
{error && <span className="text-sm text-red-500">{error.message}</span>}
</div>
)}
/>
</div>
</>
)}
</div>
))}
</div>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.sshHost?.data ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshHost", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,91 @@
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useDeleteSshHost } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshHostModal } from "./SshHostModal";
import { SshHostsTable } from "./SshHostsTable";
export const SshHostsSection = () => {
const { mutateAsync: deleteSshHost } = useDeleteSshHost();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHost",
"deleteSshHost"
] as const);
const onRemoveSshHostSubmit = async (sshHostId: string) => {
try {
const host = await deleteSshHost({ sshHostId });
createNotification({
text: `Successfully deleted SSH host: ${host.hostname}`,
type: "success"
});
handlePopUpClose("deleteSshHost");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH host",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Hosts</p>
<div className="flex w-full justify-end">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/ssh"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshHost")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Host
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<SshHostsTable handlePopUpOpen={handlePopUpOpen} />
<SshHostModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHost.isOpen}
title="Are you sure you want to remove the SSH host?"
onChange={(isOpen) => handlePopUpToggle("deleteSshHost", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostSubmit((popUp?.deleteSshHost?.data as { sshHostId: string })?.sshHostId)
}
/>
</div>
);
};
@@ -0,0 +1,180 @@
import {
faDownload,
faEllipsis,
faPencil,
faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteSshHost", "sshHost"]>,
data?: object
) => void;
};
export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspaceSshHosts(currentWorkspace?.id || "");
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
const handleDownloadUserCaKey = async (sshHostId: string) => {
try {
const publicKey = await fetchSshHostUserCaPublicKey(sshHostId);
downloadTxtFile("infisical_user_ca.pub", publicKey);
} catch (err) {
console.error("Failed to download User CA public key", err);
createNotification({
type: "error",
text: "Failed to download User CA public key"
});
}
};
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Hostname</Th>
<Th>Login User - Authorized Principals Mapping</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-ssh-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((host) => {
return (
<Tr
// className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
className="h-10"
key={`ssh-host-${host.id}`}
>
<Td>{host.hostname}</Td>
<Td>
{host.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span>
) : (
host.loginMappings.map(({ loginUser, allowedPrincipals }) => (
<div key={`${host.id}-${loginUser}`} className="mb-2">
<div className="text-mineshaft-200">{loginUser}</div>
{allowedPrincipals.usernames.map((username) => (
<div key={`${host.id}-${loginUser}-${username}`} className="ml-4">
└─ {username}
</div>
))}
</div>
))
)}
</Td>
<Td className="text-right align-middle">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={async (e) => {
e.stopPropagation();
handleDownloadUserCaKey(host.id);
}}
icon={<FontAwesomeIcon icon={faDownload} />}
>
Download User CA Public Key
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshHost", {
sshHostId: host.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit SSH host
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteSshHost", {
sshHostId: host.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete SSH host
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState title="No SSH hosts have been added" icon={faServer} />
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { SshHostsSection } from "./SshHostsSection";
@@ -1,9 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { OverviewPage } from "./OverviewPage";
import { SshHostsPage } from "./SshHostsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/overview"
)({
component: OverviewPage
component: SshHostsPage
});