rewrite to use a multi-step modal flow

This commit is contained in:
x032205
2025-10-22 02:51:58 -04:00
parent 6b9b64a338
commit 52352fc00a
11 changed files with 772 additions and 454 deletions

View File

@@ -31,6 +31,7 @@ export * from "./pkiSubscriber";
export * from "./projects";
export * from "./projectUserAdditionalPrivilege";
export * from "./rateLimit";
export * from "./relays";
export * from "./roles";
export * from "./scim";
export * from "./secretApproval";

View File

@@ -48,8 +48,8 @@ import { usePopUp } from "@app/hooks";
import { gatewaysQueryKeys, useDeleteGatewayById } from "@app/hooks/api/gateways";
import { useDeleteGatewayV2ById } from "@app/hooks/api/gateways-v2";
import { DeployGatewayModal } from "./components/DeployGatewayModal";
import { EditGatewayDetailsModal } from "./components/EditGatewayDetailsModal";
import { GatewayDeployModal } from "./components/GatewayDeployModal";
const GatewayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => {
const heartbeatDate = heartbeat ? new Date(heartbeat) : null;
@@ -269,7 +269,7 @@ export const GatewayTab = withPermission(
deleteKey="confirm"
onDeleteApproved={() => handleDeleteGateway()}
/>
<DeployGatewayModal
<GatewayDeployModal
isOpen={popUp.deployGateway.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deployGateway", isOpen)}
/>

View File

@@ -1,245 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { SingleValue } from "react-select";
import { faCopy, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
FilterableSelect,
FormLabel,
IconButton,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useOrganization } from "@app/context";
import {
useAddIdentityTokenAuth,
useCreateTokenIdentityTokenAuth,
useGetIdentityMembershipOrgs,
useGetIdentityTokenAuth
} from "@app/hooks/api";
import { useGetRelays } from "@app/hooks/api/relays";
import { slugSchema } from "@app/lib/schemas";
import { RelayOption } from "./RelayOption";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeployGatewayModal = ({ isOpen, onOpenChange }: Props) => {
const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`;
const navigate = useNavigate({
from: ROUTE_PATHS.Organization.NetworkingPage.path
});
const [name, setName] = useState("");
const [relay, setRelay] = useState<null | {
id: string;
name: string;
}>(null);
const [identity, setIdentity] = useState<null | {
id: string;
name: string;
}>(null);
const [identityToken, setIdentityToken] = useState("");
useEffect(() => {
if (!isOpen) {
setName("");
setRelay(null);
setIdentity(null);
setIdentityToken("");
}
}, [isOpen]);
const { data: relays, isPending: isRelaysLoading } = useGetRelays();
const { currentOrg } = useOrganization();
const organizationId = currentOrg?.id || "";
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
useGetIdentityMembershipOrgs({
organizationId,
limit: 20000
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
const { mutateAsync: createToken } = useCreateTokenIdentityTokenAuth();
const { mutateAsync: addIdentityTokenAuth } = useAddIdentityTokenAuth();
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
useEffect(() => {
const generateToken = async () => {
if (!identity) return;
try {
const { data: identityTokenAuth } = await refetch();
if (!identityTokenAuth) {
await addIdentityTokenAuth({
identityId: identity.id,
organizationId,
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0,
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
createNotification({
text: "Automatically enabled token authentication for this identity.",
type: "info"
});
}
const token = await createToken({
identityId: identity.id,
name: "gateway token (autogenerated)"
});
setIdentityToken(token.accessToken);
createNotification({
text: "Automatically generated a token for this identity.",
type: "info"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to generate token for identity",
type: "error"
});
setIdentityToken("");
}
};
generateToken();
}, [identity, organizationId, refetch, addIdentityTokenAuth, createToken]);
const handleIdentityChange = (selectedIdentity: SingleValue<{ id: string; name: string }>) => {
setIdentity(selectedIdentity);
setIdentityToken("");
};
const isNameValid = useMemo(() => !name || slugSchema().safeParse(name).success, [name]);
const isCommandReady = useMemo(
() => !!name && isNameValid && !!relay && !!identityToken,
[name, isNameValid, relay, identityToken]
);
const command = useMemo(
() =>
`infisical gateway start --name=${name} --relay=${
relay?.name || ""
} --domain=${siteURL} --token=${identityToken}`,
[name, relay, identityToken, siteURL]
);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Deploy Gateway"
subTitle="Configure and run the CLI command to deploy your gateway."
bodyClassName="overflow-visible"
>
<FormLabel label="Name" tooltipText="The name for your gateway." />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter gateway name..."
isError={!isNameValid}
/>
<FormLabel
label="Relay"
tooltipText="The relay to use with your gateway."
className="mt-4"
/>
<FilterableSelect
value={relay}
onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
navigate({
search: (prev) => ({ ...prev, selectedTab: "relays", action: "deploy-relay" })
});
return;
}
setRelay(newValue as SingleValue<{ id: string; name: string }>);
}}
isLoading={isRelaysLoading}
options={[
{
id: "_create",
name: "Deploy New Relay"
},
...(relays || [])
]}
placeholder="Select relay..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
components={{ Option: RelayOption }}
/>
<FormLabel
label="Identity"
tooltipText="The identity to use with your gateway."
className="mt-4"
/>
<FilterableSelect
value={identity}
onChange={(e) =>
handleIdentityChange(
e as SingleValue<{
id: string;
name: string;
}>
)
}
isLoading={isIdentitiesLoading}
placeholder="Select identity..."
options={identityMembershipOrgs.map((membership) => membership.identity)}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
<FormLabel label="CLI Command" className="mt-4" />
<div className="flex gap-2">
<Input value={command} isDisabled className={isCommandReady ? "" : "opacity-50"} />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
}}
className={twMerge("w-10", !isCommandReady && "pointer-events-none opacity-50")}
isDisabled={!isCommandReady}
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
</ModalContent>
</Modal>
);
};

View File

@@ -1,205 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { SingleValue } from "react-select";
import { faCopy, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
FilterableSelect,
FormLabel,
IconButton,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useAddIdentityTokenAuth,
useCreateTokenIdentityTokenAuth,
useGetIdentityMembershipOrgs,
useGetIdentityTokenAuth
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeployRelayModal = ({ isOpen, onOpenChange }: Props) => {
const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`;
const [name, setName] = useState("");
const [host, setHost] = useState("");
const [identity, setIdentity] = useState<null | {
id: string;
name: string;
}>(null);
const [identityToken, setIdentityToken] = useState("");
useEffect(() => {
if (!isOpen) {
setName("");
setHost("");
setIdentity(null);
setIdentityToken("");
}
}, [isOpen]);
const { currentOrg } = useOrganization();
const organizationId = currentOrg?.id || "";
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
useGetIdentityMembershipOrgs({
organizationId,
limit: 20000
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
const { mutateAsync: createToken } = useCreateTokenIdentityTokenAuth();
const { mutateAsync: addIdentityTokenAuth } = useAddIdentityTokenAuth();
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
useEffect(() => {
const generateToken = async () => {
if (!identity) return;
try {
const { data: identityTokenAuth } = await refetch();
if (!identityTokenAuth) {
await addIdentityTokenAuth({
identityId: identity.id,
organizationId,
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0,
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
createNotification({
text: "Automatically enabled token authentication for this identity.",
type: "info"
});
}
const token = await createToken({
identityId: identity.id,
name: "relay token (autogenerated)"
});
setIdentityToken(token.accessToken);
createNotification({
text: "Automatically generated a token for this identity.",
type: "info"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to generate token for identity",
type: "error"
});
setIdentityToken("");
}
};
generateToken();
}, [identity, organizationId, refetch, addIdentityTokenAuth, createToken]);
const handleIdentityChange = (selectedIdentity: SingleValue<{ id: string; name: string }>) => {
setIdentity(selectedIdentity);
setIdentityToken("");
};
const isNameValid = useMemo(() => !name || slugSchema().safeParse(name).success, [name]);
const isCommandReady = useMemo(
() => !!name && !!host && isNameValid && !!identityToken,
[name, isNameValid, host, identityToken]
);
const command = useMemo(
() =>
`infisical relay start --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`,
[name, siteURL, host, identityToken]
);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Deploy Relay"
subTitle="Configure and run the CLI command to deploy your relay."
bodyClassName="overflow-visible"
>
<FormLabel label="Name" tooltipText="The name for your relay." />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter relay name..."
isError={!isNameValid}
/>
<FormLabel
label="Host"
tooltipText="The host of the system you're deploying the relay to."
className="mt-4"
/>
<Input value={host} onChange={(e) => setHost(e.target.value)} placeholder="0.0.0.0" />
<FormLabel
label="Identity"
tooltipText="The identity to use with your relay."
className="mt-4"
/>
<FilterableSelect
value={identity}
onChange={(e) =>
handleIdentityChange(
e as SingleValue<{
id: string;
name: string;
}>
)
}
isLoading={isIdentitiesLoading}
placeholder="Select identity..."
options={identityMembershipOrgs.map((membership) => membership.identity)}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
<FormLabel label="CLI Command" className="mt-4" />
<div className="flex gap-2">
<Input value={command} isDisabled className={isCommandReady ? "" : "opacity-50"} />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
}}
className={twMerge("w-10", !isCommandReady && "pointer-events-none opacity-50")}
isDisabled={!isCommandReady}
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,297 @@
import { useMemo, useState } from "react";
import { SingleValue } from "react-select";
import { faCopy, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormLabel,
IconButton,
Input,
ModalClose
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useOrganization } from "@app/context";
import {
useAddIdentityTokenAuth,
useCreateTokenIdentityTokenAuth,
useGetIdentityMembershipOrgs,
useGetIdentityTokenAuth,
useGetRelays
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
import { RelayOption } from "./RelayOption";
const formSchema = z.object({
name: slugSchema({ field: "name" }),
instanceDomain: z.string().url("Must be a valid URL").or(z.literal("")),
relay: z
.object(
{
id: z.string(),
name: z.string()
},
{ required_error: "Relay is required" }
)
.nullable()
.refine((val) => val !== null, { message: "Relay is required" }),
identity: z
.object(
{
id: z.string(),
name: z.string()
},
{ required_error: "Identity is required" }
)
.nullable()
.refine((val) => val !== null, { message: "Identity is required" })
});
export const GatewayCliDeploymentMethod = () => {
const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`;
const navigate = useNavigate({
from: ROUTE_PATHS.Organization.NetworkingPage.path
});
const [step, setStep] = useState<"form" | "command">("form");
const [name, setName] = useState("");
const [instanceDomain, setInstanceDomain] = useState(siteURL);
const [relay, setRelay] = useState<null | {
id: string;
name: string;
}>(null);
const [identity, setIdentity] = useState<null | {
id: string;
name: string;
}>(null);
const [identityToken, setIdentityToken] = useState("");
const [formErrors, setFormErrors] = useState<z.ZodIssue[]>([]);
const errors = useMemo(() => {
const errorMap: Record<string, string | undefined> = {};
formErrors.forEach((issue) => {
if (issue.path.length > 0) {
errorMap[String(issue.path[0])] = issue.message;
}
});
return errorMap;
}, [formErrors]);
const { data: relays, isPending: isRelaysLoading } = useGetRelays();
const { currentOrg } = useOrganization();
const organizationId = currentOrg?.id || "";
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
useGetIdentityMembershipOrgs({
organizationId,
limit: 20000
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
const { mutateAsync: createToken, isPending: isCreatingToken } =
useCreateTokenIdentityTokenAuth();
const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } =
useAddIdentityTokenAuth();
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
const handleGenerateCommand = async () => {
setFormErrors([]);
const validation = formSchema.safeParse({ name, relay, identity, instanceDomain });
if (!validation.success) {
setFormErrors(validation.error.issues);
return;
}
const validatedIdentity = validation.data.identity;
try {
const { data: identityTokenAuth } = await refetch();
if (!identityTokenAuth) {
await addIdentityTokenAuth({
identityId: validatedIdentity.id,
organizationId,
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0,
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
createNotification({
text: "Automatically enabled token authentication for this identity.",
type: "info"
});
}
const token = await createToken({
identityId: validatedIdentity.id,
name: "gateway token (autogenerated)"
});
setIdentityToken(token.accessToken);
createNotification({
text: "Automatically generated a token for this identity.",
type: "info"
});
setStep("command");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to generate token for identity",
type: "error"
});
setIdentityToken("");
}
};
const command = useMemo(() => {
const domainFlag = instanceDomain ? ` --domain=${instanceDomain}` : "";
return `infisical gateway start --name=${name} --relay=${
relay?.name || ""
}${domainFlag} --token=${identityToken}`;
}, [name, relay, identityToken, instanceDomain]);
if (step === "command") {
return (
<>
<FormLabel label="CLI Command" />
<div className="flex gap-2">
<Input value={command} isDisabled />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
<div className="mt-6 flex items-center">
<ModalClose asChild>
<Button className="mr-4" size="sm" colorSchema="secondary">
Done
</Button>
</ModalClose>
</div>
</>
);
}
return (
<>
<FormLabel label="Name" tooltipText="The name for your gateway." />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter gateway name..."
isError={Boolean(errors.name)}
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
<FormLabel label="Relay" tooltipText="The relay to use with your gateway." className="mt-4" />
<FilterableSelect
value={relay}
onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
navigate({
search: (prev) => ({ ...prev, selectedTab: "relays", action: "deploy-relay" })
});
return;
}
setRelay(newValue as SingleValue<{ id: string; name: string }>);
}}
isLoading={isRelaysLoading}
options={[
{
id: "_create",
name: "Deploy New Relay"
},
...(relays || [])
]}
placeholder="Select relay..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
components={{ Option: RelayOption }}
/>
{errors.relay && <p className="mt-1 text-sm text-red-500">{errors.relay}</p>}
<FormLabel
label="Instance Domain"
tooltipText="The domain of the infisical instance that's accessible by the gateway."
className="mt-4"
/>
<Input
value={instanceDomain}
onChange={(e) => setInstanceDomain(e.target.value)}
placeholder="https://app.infisical.com"
isError={Boolean(errors.instanceDomain)}
/>
{errors.instanceDomain && (
<p className="mt-1 text-sm text-red-500">{errors.instanceDomain}</p>
)}
<FormLabel
label="Identity"
tooltipText="The identity that your gateway will use for authentication."
className="mt-4"
/>
<FilterableSelect
value={identity}
onChange={(e) =>
setIdentity(
e as SingleValue<{
id: string;
name: string;
}>
)
}
isLoading={isIdentitiesLoading}
placeholder="Select identity..."
options={identityMembershipOrgs.map((membership) => membership.identity)}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
{errors.identity && <p className="mt-1 text-sm text-red-500">{errors.identity}</p>}
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
colorSchema="secondary"
onClick={handleGenerateCommand}
isLoading={isCreatingToken || isAddingTokenAuth}
>
Continue
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { GatewayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect";
import { GatewayCliDeploymentMethod } from "./GatewayCliDeploymentMethod";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const GatewayDeploymentInfoMap = {
cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod }
} as const;
export type GatewayDeploymentMethod = keyof typeof GatewayDeploymentInfoMap;
const Content = () => {
const [selectedMethod, setSelectedMethod] = useState<null | GatewayDeploymentMethod>(null);
if (selectedMethod) {
const ComponentToRender = GatewayDeploymentInfoMap[selectedMethod]?.component;
if (ComponentToRender) {
return <ComponentToRender />;
}
}
return <GatewayDeploymentMethodSelect onSelect={setSelectedMethod} />;
};
export const GatewayDeployModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Deploy Gateway"
subTitle="Select a deployment method to use for the gateway."
bodyClassName="overflow-visible"
>
<Content />
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import {
GatewayDeploymentInfoMap,
GatewayDeploymentMethod
} from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal";
type Props = {
onSelect: (method: GatewayDeploymentMethod) => void;
};
export const GatewayDeploymentMethodSelect = ({ onSelect }: Props) => {
const deploymentOptions = useMemo(
() =>
(Object.keys(GatewayDeploymentInfoMap) as GatewayDeploymentMethod[]).map((method) => ({
method,
name: GatewayDeploymentInfoMap[method].name,
image: GatewayDeploymentInfoMap[method].image
})),
[]
);
const handleResourceSelect = (method: GatewayDeploymentMethod) => {
onSelect(method);
};
return (
<div className="grid h-118 grid-cols-4 content-start gap-2">
{deploymentOptions.map((option) => {
const { image, name } = option;
return (
<button
type="button"
onClick={() => handleResourceSelect(option.method)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: "50px"
}}
className="mt-auto"
alt={`${name} logo`}
/>
</div>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
</div>
);
};

View File

@@ -46,7 +46,7 @@ import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays";
import { DeployRelayModal } from "../GatewayTab/components/DeployRelayModal";
import { RelayDeployModal } from "./components/RelayDeployModal";
const RelayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => {
const heartbeatDate = heartbeat ? new Date(heartbeat) : null;
@@ -258,7 +258,7 @@ export const RelayTab = withPermission(
deleteKey="confirm"
onDeleteApproved={() => handleDeleteRelay()}
/>
<DeployRelayModal
<RelayDeployModal
isOpen={popUp.deployRelay.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deployRelay", isOpen)}
/>

View File

@@ -0,0 +1,268 @@
import { useMemo, useState } from "react";
import { SingleValue } from "react-select";
import { faCopy, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormLabel,
IconButton,
Input,
ModalClose
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useAddIdentityTokenAuth,
useCreateTokenIdentityTokenAuth,
useGetIdentityMembershipOrgs,
useGetIdentityTokenAuth
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: slugSchema({ field: "name" }),
host: z.string().min(1, "Host is required"),
instanceDomain: z.string().url("Must be a valid URL").or(z.literal("")),
identity: z
.object(
{
id: z.string(),
name: z.string()
},
{ required_error: "Identity is required" }
)
.nullable()
.refine((val) => val !== null, { message: "Identity is required" })
});
export const RelayCliDeploymentMethod = () => {
const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`;
const [step, setStep] = useState<"form" | "command">("form");
const [name, setName] = useState("");
const [host, setHost] = useState("");
const [instanceDomain, setInstanceDomain] = useState(siteURL);
const [identity, setIdentity] = useState<null | {
id: string;
name: string;
}>(null);
const [identityToken, setIdentityToken] = useState("");
const [formErrors, setFormErrors] = useState<z.ZodIssue[]>([]);
const errors = useMemo(() => {
const errorMap: Record<string, string | undefined> = {};
formErrors.forEach((issue) => {
if (issue.path.length > 0) {
errorMap[String(issue.path[0])] = issue.message;
}
});
return errorMap;
}, [formErrors]);
const { currentOrg } = useOrganization();
const organizationId = currentOrg?.id || "";
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
useGetIdentityMembershipOrgs({
organizationId,
limit: 20000
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
const { mutateAsync: createToken, isPending: isCreatingToken } =
useCreateTokenIdentityTokenAuth();
const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } =
useAddIdentityTokenAuth();
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
const handleGenerateCommand = async () => {
setFormErrors([]);
const validation = formSchema.safeParse({ name, host, instanceDomain, identity });
if (!validation.success) {
setFormErrors(validation.error.issues);
return;
}
const validatedIdentity = validation.data.identity;
try {
const { data: identityTokenAuth } = await refetch();
if (!identityTokenAuth) {
await addIdentityTokenAuth({
identityId: validatedIdentity.id,
organizationId,
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0,
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
createNotification({
text: "Automatically enabled token authentication for this identity.",
type: "info"
});
}
const token = await createToken({
identityId: validatedIdentity.id,
name: "relay token (autogenerated)"
});
setIdentityToken(token.accessToken);
createNotification({
text: "Automatically generated a token for this identity.",
type: "info"
});
setStep("command");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to generate token for identity",
type: "error"
});
setIdentityToken("");
}
};
const handleIdentityChange = (
selectedIdentity: SingleValue<{
id: string;
name: string;
}>
) => {
setIdentity(selectedIdentity);
};
const command = useMemo(() => {
const domainFlag = instanceDomain ? ` --domain=${instanceDomain}` : "";
return `infisical relay start --name=${name}${domainFlag} --host=${host} --token=${identityToken}`;
}, [name, instanceDomain, host, identityToken]);
if (step === "command") {
return (
<>
<FormLabel label="CLI Command" />
<div className="flex gap-2">
<Input value={command} isDisabled />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
<div className="mt-6 flex items-center">
<ModalClose asChild>
<Button className="mr-4" size="sm" colorSchema="secondary">
Done
</Button>
</ModalClose>
</div>
</>
);
}
return (
<>
<FormLabel label="Name" tooltipText="The name for your relay." />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter relay name..."
isError={Boolean(errors.name)}
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
<FormLabel
label="Host"
tooltipText="The public IP address of the system you're deploying the relay to."
className="mt-4"
/>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="0.0.0.0"
isError={Boolean(errors.host)}
/>
{errors.host && <p className="mt-1 text-sm text-red-500">{errors.host}</p>}
<FormLabel
label="Instance Domain"
tooltipText="The domain of the infisical instance that's accessible by the relay."
className="mt-4"
/>
<Input
value={instanceDomain}
onChange={(e) => setInstanceDomain(e.target.value)}
placeholder="https://app.infisical.com"
isError={Boolean(errors.instanceDomain)}
/>
{errors.instanceDomain && (
<p className="mt-1 text-sm text-red-500">{errors.instanceDomain}</p>
)}
<FormLabel
label="Identity"
tooltipText="The identity that your relay will use for authentication."
className="mt-4"
/>
<FilterableSelect
value={identity}
onChange={(e) =>
handleIdentityChange(
e as SingleValue<{
id: string;
name: string;
}>
)
}
isLoading={isIdentitiesLoading}
placeholder="Select identity..."
options={identityMembershipOrgs.map((membership) => membership.identity)}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
{errors.identity && <p className="mt-1 text-sm text-red-500">{errors.identity}</p>}
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
colorSchema="secondary"
onClick={handleGenerateCommand}
isLoading={isCreatingToken || isAddingTokenAuth}
>
Continue
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect";
import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const RelayDeploymentInfoMap = {
cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod }
} as const;
export type RelayDeploymentMethod = keyof typeof RelayDeploymentInfoMap;
const Content = () => {
const [selectedMethod, setSelectedMethod] = useState<null | RelayDeploymentMethod>(null);
if (selectedMethod) {
const ComponentToRender = RelayDeploymentInfoMap[selectedMethod]?.component;
if (ComponentToRender) {
return <ComponentToRender />;
}
}
return <RelayDeploymentMethodSelect onSelect={setSelectedMethod} />;
};
export const RelayDeployModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Deploy Relay"
subTitle="Select a deployment method to use for the relay."
bodyClassName="overflow-visible"
>
<Content />
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import {
RelayDeploymentInfoMap,
RelayDeploymentMethod
} from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal";
type Props = {
onSelect: (method: RelayDeploymentMethod) => void;
};
export const RelayDeploymentMethodSelect = ({ onSelect }: Props) => {
const deploymentOptions = useMemo(
() =>
(Object.keys(RelayDeploymentInfoMap) as RelayDeploymentMethod[]).map((method) => ({
method,
name: RelayDeploymentInfoMap[method].name,
image: RelayDeploymentInfoMap[method].image
})),
[]
);
const handleResourceSelect = (method: RelayDeploymentMethod) => {
onSelect(method);
};
return (
<div className="grid h-118 grid-cols-4 content-start gap-2">
{deploymentOptions.map((option) => {
const { image, name } = option;
return (
<button
type="button"
onClick={() => handleResourceSelect(option.method)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: "50px"
}}
className="mt-auto"
alt={`${name} logo`}
/>
</div>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
</div>
);
};