mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 19:16:22 +00:00
Merge branch 'main' into feat/terraformCloudIntegration
This commit is contained in:
+1
-1
@@ -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 = {
|
||||
|
||||
+16
-2
@@ -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"
|
||||
|
||||
+58
-3
@@ -19,6 +19,7 @@ import {
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretRotationActions,
|
||||
ProjectPermissionSecretSyncActions,
|
||||
ProjectPermissionSshHostActions,
|
||||
TPermissionCondition,
|
||||
TPermissionConditionOperators
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -108,6 +109,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 +224,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 +256,7 @@ type TConditionalFields =
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| ProjectPermissionSub.SshHosts
|
||||
| ProjectPermissionSub.SecretRotation
|
||||
| ProjectPermissionSub.Identity;
|
||||
|
||||
@@ -251,8 +267,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 +325,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
|
||||
@@ -577,7 +597,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;
|
||||
};
|
||||
|
||||
@@ -901,6 +946,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: [
|
||||
|
||||
+9
-1
@@ -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"
|
||||
|
||||
+173
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+7
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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">
|
||||
+2
-2
@@ -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
@@ -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";
|
||||
+2
-2
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user