feat: add support for org relay registration

This commit is contained in:
Sheen Capadngan
2025-09-19 01:52:33 +08:00
parent 31c1006760
commit 88176daf47
18 changed files with 760 additions and 13 deletions
@@ -0,0 +1,214 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import {
faArrowUpRightFromSquare,
faBookOpen,
faCopy,
faDoorClosed,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatRelative } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
PageHeader,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
OrgPermissionSubjects,
OrgRelayPermissionActions
} from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays";
export const RelayListPage = withPermission(
() => {
const [search, setSearch] = useState("");
const { data: relays, isPending: isRelaysLoading } = useGetRelays();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteRelay"] as const);
const deleteRelayById = useDeleteRelayById();
const handleDeleteRelay = async () => {
const data = popUp.deleteRelay.data as { id: string };
await deleteRelayById.mutateAsync(data.id);
handlePopUpToggle("deleteRelay");
createNotification({
type: "success",
text: "Successfully deleted relay"
});
};
const filteredRelays = relays?.filter((el) =>
el.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-bunker-800">
<Helmet>
<title>Infisical | Relays</title>
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader
className="w-full"
title={
<div className="flex w-full items-center">
<span>Relays</span>
<a
className="-mt-1.5"
href="https://infisical.com/docs/documentation/platform/relays/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 text-sm font-normal text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
}
description="Create and configure relays to securely access private network resources from Infisical"
/>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search relay..."
className="flex-1"
/>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">Name</Th>
<Th>Host</Th>
<Th>Created</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isRelaysLoading && (
<TableSkeleton innerKey="relay-table" columns={4} key="relay-table" />
)}
{filteredRelays?.map((el) => (
<Tr key={el.id}>
<Td>
<div className="flex items-center gap-2">
<span>{el.name}</span>
{!el.orgId && (
<Tooltip content="This is an instance-level relay available to all organizations">
<span className="rounded bg-mineshaft-700 px-1.5 py-0.5 text-xs text-mineshaft-400">
Instance
</span>
</Tooltip>
)}
</div>
</Td>
<Td>{el.host}</Td>
<Td>{formatRelative(new Date(el.createdAt), new Date())}</Td>
<Td className="w-5">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCopy} />}
onClick={() => navigator.clipboard.writeText(el.id)}
>
Copy ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgRelayPermissionActions.DeleteRelays}
a={OrgPermissionSubjects.Relay}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed || !el.orgId}
icon={<FontAwesomeIcon icon={faTrash} />}
className="text-red"
onClick={() => handlePopUpOpen("deleteRelay", el)}
>
Delete Relay
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
))}
</TBody>
</Table>
{!isRelaysLoading && !filteredRelays?.length && (
<EmptyState
title={
relays?.length
? "No Relays match search..."
: "No Relays have been configured"
}
icon={relays?.length ? faSearch : faDoorClosed}
/>
)}
<DeleteActionModal
isOpen={popUp.deleteRelay.isOpen}
title={`Are you sure you want to delete relay ${
(popUp?.deleteRelay?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteRelay", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => handleDeleteRelay()}
/>
</TableContainer>
</div>
</div>
</div>
</div>
</div>
);
},
{ action: OrgRelayPermissionActions.ListRelays, subject: OrgPermissionSubjects.Relay }
);
@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { RelayListPage } from "./RelayListPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/relays/"
)({
component: RelayListPage,
context: () => ({
breadcrumbs: [
{
label: "Relays"
}
]
})
});
@@ -11,7 +11,8 @@ import {
OrgPermissionIdentityActions,
OrgPermissionKmipActions,
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSecretShareAction
OrgPermissionSecretShareAction,
OrgRelayPermissionActions
} from "@app/context/OrgPermissionContext/types";
import { TPermission } from "@app/hooks/api/roles/types";
@@ -90,6 +91,15 @@ const orgGatewayPermissionSchema = z
})
.optional();
const orgRelayPermissionSchema = z
.object({
[OrgRelayPermissionActions.ListRelays]: z.boolean().optional(),
[OrgRelayPermissionActions.EditRelays]: z.boolean().optional(),
[OrgRelayPermissionActions.DeleteRelays]: z.boolean().optional(),
[OrgRelayPermissionActions.CreateRelays]: z.boolean().optional()
})
.optional();
const machineIdentityAuthTemplatePermissionSchema = z
.object({
[OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]: z.boolean().optional(),
@@ -147,6 +157,7 @@ export const formSchema = z.object({
"app-connections": appConnectionsPermissionSchema,
kmip: kmipPermissionSchema,
gateway: orgGatewayPermissionSchema,
relay: orgRelayPermissionSchema,
"machine-identity-auth-template": machineIdentityAuthTemplatePermissionSchema,
"secret-share": secretSharingPermissionSchema
})
@@ -0,0 +1,180 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { OrgRelayPermissionActions } from "@app/context/OrgPermissionContext/types";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "../OrgRoleModifySection.utils";
type Props = {
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-access",
Custom = "custom"
}
const PERMISSION_ACTIONS = [
{ action: OrgRelayPermissionActions.ListRelays, label: "List Relays" },
{ action: OrgRelayPermissionActions.CreateRelays, label: "Create Relays" },
{ action: OrgRelayPermissionActions.EditRelays, label: "Edit Relays" },
{ action: OrgRelayPermissionActions.DeleteRelays, label: "Delete Relays" }
] as const;
export const OrgRelayPermissionRow = ({ isEditable, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: "permissions.relay"
});
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSION_ACTIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (score === 1 && rule?.[OrgRelayPermissionActions.ListRelays]) return Permission.ReadOnly;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
useEffect(() => {
const isRowCustom = selectedPermissionCategory === Permission.Custom;
if (isRowCustom) {
setIsRowExpanded.on();
}
}, []);
const handlePermissionChange = (val: Permission) => {
if (!val) return;
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
setIsCustom.off();
switch (val) {
case Permission.FullAccess:
setValue(
"permissions.relay",
{
[OrgRelayPermissionActions.ListRelays]: true,
[OrgRelayPermissionActions.EditRelays]: true,
[OrgRelayPermissionActions.CreateRelays]: true,
[OrgRelayPermissionActions.DeleteRelays]: true
},
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
"permissions.relay",
{
[OrgRelayPermissionActions.ListRelays]: true,
[OrgRelayPermissionActions.EditRelays]: false,
[OrgRelayPermissionActions.CreateRelays]: false,
[OrgRelayPermissionActions.DeleteRelays]: false
},
{ shouldDirty: true }
);
break;
case Permission.NoAccess:
default:
setValue(
"permissions.relay",
{
[OrgRelayPermissionActions.ListRelays]: false,
[OrgRelayPermissionActions.EditRelays]: false,
[OrgRelayPermissionActions.CreateRelays]: false,
[OrgRelayPermissionActions.DeleteRelays]: false
},
{ shouldDirty: true }
);
}
};
return (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td className="w-4">
<FontAwesomeIcon className="w-4" icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td className="w-full select-none">Relays</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="h-8 w-40 bg-mineshaft-700"
dropdownContainerClassName="border text-left border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
position="popper"
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td colSpan={3} className="border-mineshaft-500 bg-mineshaft-900 p-8">
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.relay.${action}`}
key={`permissions.relay.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={Boolean(field.value)}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.relays.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -69,6 +69,7 @@ type Props = {
| "organization-admin-console"
| "kmip"
| "gateway"
| "relay"
| "secret-share"
| "billing"
| "audit-logs"
@@ -23,6 +23,7 @@ import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow";
import { OrgPermissionIdentityRow } from "./OrgPermissionIdentityRow";
import { OrgPermissionKmipRow } from "./OrgPermissionKmipRow";
import { OrgPermissionMachineIdentityAuthTemplateRow } from "./OrgPermissionMachineIdentityAuthTemplateRow";
import { OrgRelayPermissionRow } from "./OrgPermissionRelayRow";
import { OrgPermissionSecretShareRow } from "./OrgPermissionSecretShareRow";
import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow";
import { RolePermissionRow } from "./RolePermissionRow";
@@ -188,6 +189,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgRelayPermissionRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionBillingRow
control={control}
setValue={setValue}