Fix adding res and add missing forms

This commit is contained in:
Fang-Pen Lin
2025-12-02 21:18:03 -08:00
parent e12f30cd43
commit 5ac4e0a5bb
8 changed files with 365 additions and 8 deletions

View File

@@ -67,10 +67,11 @@ export const executeWithGateway = async <T>(
async (proxyPort) => {
const protocol = url.protocol === "https:" ? "https" : "http";
const baseUrl = `${protocol}://localhost:${proxyPort}`;
// const baseUrl = `http://localhost:${proxyPort}`;
return operation(baseUrl, httpsAgent);
},
{
protocol: GatewayProxyProtocol.Http,
protocol: GatewayProxyProtocol.Tcp,
relayHost: platformConnectionDetails.relayHost,
gateway: platformConnectionDetails.gateway,
relay: platformConnectionDetails.relay,
@@ -92,9 +93,6 @@ export const kubernetesResourceFactory: TPamResourceFactory<
// Validate connection by checking API server version
try {
await axios.get(`${baseUrl}/version`, {
headers: {
"Content-Type": "application/json"
},
...(httpsAgent ? { httpsAgent } : {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT

View File

@@ -6,17 +6,19 @@ import {
PamResourceType,
PamSessionStatus
} from "../enums";
import { TKubernetesAccount, TKubernetesResource } from "./kubernetes-resource";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource";
export * from "./kubernetes-resource";
export * from "./mysql-resource";
export * from "./postgres-resource";
export * from "./ssh-resource";
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TKubernetesResource;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TKubernetesAccount;
export type TPamFolder = {
id: string;

View File

@@ -0,0 +1,35 @@
import { PamResourceType } from "../enums";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
export enum KubernetesAuthMethod {
ServiceAccountToken = "service-account-token"
}
export type TKubernetesConnectionDetails = {
url: string;
namespace: string;
skipTLSVerify: boolean;
caCertificate?: string;
};
export type TKubernetesServiceAccountTokenCredentials = {
authMethod: KubernetesAuthMethod.ServiceAccountToken;
serviceAccountName: string;
serviceAccountToken: string;
};
export type TKubernetesCredentials = TKubernetesServiceAccountTokenCredentials;
// Resources
export type TKubernetesResource = TBasePamResource & {
resourceType: PamResourceType.Kubernetes;
} & {
connectionDetails: TKubernetesConnectionDetails;
rotationAccountCredentials?: TKubernetesCredentials | null;
};
// Accounts
export type TKubernetesAccount = TBasePamAccount & {
credentials: TKubernetesCredentials;
};

View File

@@ -0,0 +1,96 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { KubernetesAuthMethod, PamResourceType, TKubernetesResource } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { KubernetesResourceFields } from "./shared/KubernetesResourceFields";
import { KubernetesRotateAccountFields } from "./shared/KubernetesRotateAccountFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = {
resource?: TKubernetesResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const KubernetesConnectionDetailsSchema = z.object({
url: z.string().url().trim().max(500),
namespace: z.string().trim().max(255),
skipTLSVerify: z.boolean(),
caCertificate: z.string().trim().max(10000).optional()
});
const KubernetesServiceAccountTokenCredentialsSchema = z.object({
authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken),
serviceAccountName: z.string().trim().max(255),
serviceAccountToken: z.string().trim().max(10000)
});
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.Kubernetes),
connectionDetails: KubernetesConnectionDetailsSchema,
rotationAccountCredentials: KubernetesServiceAccountTokenCredentialsSchema.nullable().optional()
});
type FormData = z.infer<typeof formSchema>;
export const KubernetesResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource
? {
...resource,
rotationAccountCredentials: resource.rotationAccountCredentials
? {
...resource.rotationAccountCredentials,
serviceAccountToken: UNCHANGED_PASSWORD_SENTINEL
}
: resource.rotationAccountCredentials
}
: {
resourceType: PamResourceType.Kubernetes,
connectionDetails: {
url: "",
namespace: "default",
skipTLSVerify: false,
caCertificate: undefined
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericResourceFields />
<KubernetesResourceFields />
<KubernetesRotateAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Details" : "Create Resource"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -9,6 +9,7 @@ import {
import { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader";
import { KubernetesResourceForm } from "./KubernetesResourceForm";
import { MySQLResourceForm } from "./MySQLResourceForm";
import { PostgresResourceForm } from "./PostgresResourceForm";
import { SSHResourceForm } from "./SSHResourceForm";
@@ -54,6 +55,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
return <MySQLResourceForm onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm onSubmit={onSubmit} />;
case PamResourceType.Kubernetes:
return <KubernetesResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.Kubernetes:
return <KubernetesResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
}

View File

@@ -0,0 +1,79 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input, Switch, TextArea } from "@app/components/v2";
export const KubernetesResourceFields = () => {
const { control, watch } = useFormContext();
const skipTLSVerify = watch("connectionDetails.skipTLSVerify");
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<div className="mt-[0.675rem] flex flex-col gap-4">
<Controller
name="connectionDetails.url"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Kubernetes API URL"
>
<Input placeholder="https://kubernetes.example.com:6443" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.namespace"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Namespace"
>
<Input placeholder="default" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.skipTLSVerify"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="skip-tls-verify"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Skip TLS Verification
</Switch>
</FormControl>
)}
/>
<Controller
name="connectionDetails.caCertificate"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={skipTLSVerify ? "opacity-50" : ""}
label="CA Certificate"
isOptional
>
<TextArea
className="h-14 resize-none!"
{...field}
isDisabled={skipTLSVerify}
placeholder="-----BEGIN CERTIFICATE-----..."
/>
</FormControl>
)}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { FormControl, Input, Switch } from "@app/components/v2";
import { KubernetesAuthMethod } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
export const KubernetesRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control, setValue, getValues } = useFormContext();
const [showToken, setShowToken] = useState(false);
const rotationServiceAccountName = useWatch({
control,
name: "rotationAccountCredentials.serviceAccountName"
});
const rotationServiceAccountToken = useWatch({
control,
name: "rotationAccountCredentials.serviceAccountToken"
});
const [enabled, setEnabled] = useState(false);
const [wasRotationTokenSentinelInitially, setWasRotationTokenSentinelInitially] = useState(false);
useEffect(() => {
const initialRotationToken = getValues("rotationAccountCredentials.serviceAccountToken");
if (initialRotationToken === UNCHANGED_PASSWORD_SENTINEL) {
setWasRotationTokenSentinelInitially(true);
}
}, [getValues]);
useEffect(() => {
if (rotationServiceAccountToken === UNCHANGED_PASSWORD_SENTINEL) {
setShowToken(false);
}
}, [rotationServiceAccountToken]);
useEffect(() => {
const isServiceAccountNamePopulated =
rotationServiceAccountName && rotationServiceAccountName !== "";
const isTokenPopulated =
rotationServiceAccountToken &&
rotationServiceAccountToken !== "" &&
rotationServiceAccountToken !== UNCHANGED_PASSWORD_SENTINEL;
if (isServiceAccountNamePopulated || isTokenPopulated) {
setEnabled(true);
}
}, [rotationServiceAccountName, rotationServiceAccountToken]);
return (
<div className="flex flex-col gap-2">
<Switch
id="account-rotation"
onCheckedChange={(value) => {
setEnabled(value);
if (value) {
setValue(
"rotationAccountCredentials.authMethod",
KubernetesAuthMethod.ServiceAccountToken,
{
shouldDirty: true
}
);
setValue("rotationAccountCredentials.serviceAccountName", "", {
shouldDirty: true
});
setValue("rotationAccountCredentials.serviceAccountToken", "", {
shouldDirty: true
});
} else {
setValue("rotationAccountCredentials", null, {
shouldDirty: true
});
}
}}
isChecked={enabled}
containerClassName="flex-row-reverse w-fit"
className="ml-0"
>
<p className="ml-2">Credential Rotation</p>
</Switch>
{enabled && (
<>
<div className="flex gap-2">
<Controller
name="rotationAccountCredentials.serviceAccountName"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Service Account Name"
>
<div className="relative">
<Input {...field} autoComplete="off" />
</div>
</FormControl>
)}
/>
<Controller
name="rotationAccountCredentials.serviceAccountToken"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Service Account Token"
>
<div className="relative">
<Input
{...field}
type={showToken ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
setShowToken(true);
}}
onBlur={() => {
if (isUpdate && field.value === "" && wasRotationTokenSentinelInitially) {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
setShowToken(false);
}}
/>
</div>
</FormControl>
)}
/>
</div>
<p className="mb-2 text-xs text-mineshaft-400">
Credentials of the privileged service account which will be used for rotating other
accounts under this resource
</p>
</>
)}
</div>
);
};

View File

@@ -38,7 +38,6 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
{ name: "Redis", resource: PamResourceType.Redis },
{ name: "RDP", resource: PamResourceType.RDP },
{ name: "SSH", resource: PamResourceType.SSH },
{ name: "Kubernetes", resource: PamResourceType.Kubernetes },
{ name: "MCP", resource: PamResourceType.MCP },
{ name: "Web Application", resource: PamResourceType.WebApp }
];
@@ -78,7 +77,6 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
// We temporarily show a special license modal for these because we will have to write some code to complete the integration
if (
resource === PamResourceType.RDP ||
resource === PamResourceType.Kubernetes ||
resource === PamResourceType.MCP ||
resource === PamResourceType.Redis ||
resource === PamResourceType.MongoDB ||