mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 17:40:44 +00:00
Merge branch 'main' into ENG-2636
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
+10
-5
@@ -25,6 +25,7 @@ import {
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { useGetProjectTypeFromRoute, usePopUp } from "@app/hooks";
|
||||
import { TProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { DeleteProjectTemplateModal } from "./DeleteProjectTemplateModal";
|
||||
|
||||
@@ -40,6 +41,7 @@ export const ProjectTemplatesTable = ({ onEdit }: Props) => {
|
||||
const { isPending, data: projectTemplates = [] } = useListProjectTemplates(projectType, {
|
||||
enabled: subscription?.projectTemplates && Boolean(projectType)
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteTemplate"] as const);
|
||||
@@ -52,7 +54,9 @@ export const ProjectTemplatesTable = ({ onEdit }: Props) => {
|
||||
[search, projectTemplates]
|
||||
);
|
||||
|
||||
const isSecretManagerTemplates = projectType === "secret-manager";
|
||||
const isSecretManagerTemplates = projectType === ProjectType.SecretManager;
|
||||
|
||||
const colSpan = isSecretManagerTemplates ? 4 : 3;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -73,10 +77,10 @@ export const ProjectTemplatesTable = ({ onEdit }: Props) => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && (
|
||||
{subscription?.projectTemplates && isPending && (
|
||||
<TableSkeleton
|
||||
innerKey="project-templates-table"
|
||||
columns={4}
|
||||
columns={colSpan}
|
||||
key="project-templates"
|
||||
/>
|
||||
)}
|
||||
@@ -170,9 +174,10 @@ export const ProjectTemplatesTable = ({ onEdit }: Props) => {
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isPending && filteredTemplates?.length === 0 && (
|
||||
{(!subscription?.projectTemplates ||
|
||||
(!isPending && filteredTemplates?.length === 0)) && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<Td colSpan={colSpan}>
|
||||
<EmptyState
|
||||
title={
|
||||
search.trim()
|
||||
|
||||
@@ -7,9 +7,10 @@ type Props = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
|
||||
export const NoticeBannerV2 = ({ title, children, className }: Props) => {
|
||||
export const NoticeBannerV2 = ({ title, children, className, titleClassName }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -17,7 +18,7 @@ export const NoticeBannerV2 = ({ title, children, className }: Props) => {
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center text-sm">
|
||||
<div className={twMerge("mb-1 flex items-center text-sm", titleClassName)}>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
{title}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@ export enum LoginMappingSource {
|
||||
export type TLoginMapping = {
|
||||
loginUser: string;
|
||||
allowedPrincipals: {
|
||||
usernames: string[];
|
||||
usernames?: string[];
|
||||
groups?: string[];
|
||||
};
|
||||
source: LoginMappingSource;
|
||||
};
|
||||
|
||||
@@ -16,8 +16,11 @@ export const workspaceKeys = {
|
||||
type ? ["workspaces", { type }] : (["workspaces"] as const),
|
||||
getWorkspaceAuditLogs: (workspaceId: string) =>
|
||||
[{ workspaceId }, "workspace-audit-logs"] as const,
|
||||
getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) =>
|
||||
[{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const,
|
||||
getWorkspaceUsers: (
|
||||
workspaceId: string,
|
||||
includeGroupMembers: boolean = false,
|
||||
roles: string[] = []
|
||||
) => [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const,
|
||||
getWorkspaceUserDetails: (workspaceId: string, membershipId: string) =>
|
||||
[{ workspaceId, membershipId }, "workspace-user-details"] as const,
|
||||
getWorkspaceIdentityMemberships: (workspaceId: string) =>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const MicrosoftTeamsIntegrationForm = ({ adminIntegrationsConfig }: Props
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Updated admin Microsoft Teams configuration",
|
||||
text: "Updated admin Microsoft Teams configuration. It can take up to 5 minutes to take effect.",
|
||||
type: "success"
|
||||
});
|
||||
};
|
||||
|
||||
+47
-15
@@ -1,9 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
|
||||
import { ImportTab } from "../ImportTab";
|
||||
@@ -36,22 +38,52 @@ export const OrgTabGroup = () => {
|
||||
component: () => (
|
||||
<div>
|
||||
<NoticeBannerV2
|
||||
className="mx-auto mt-10 max-w-4xl"
|
||||
title="Project Templates New Location"
|
||||
className="mx-auto"
|
||||
titleClassName="text-base"
|
||||
title="Project Templates Relocated"
|
||||
>
|
||||
<p className="text-sm text-bunker-200">
|
||||
Project templates have been moved to the Feature Settings page, under the "Project
|
||||
Templates" tab.
|
||||
<p className="mt-1 text-mineshaft-300">
|
||||
Project templates have been relocated and are now product specific:
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-bunker-200">
|
||||
Project templates are now product-specific, and can be configured for each project
|
||||
type.
|
||||
</p>
|
||||
<img
|
||||
src="/images/project-templates/project-templates-new-location.png"
|
||||
className="mt-4 w-full max-w-4xl rounded"
|
||||
alt="Project Templates New Location"
|
||||
/>
|
||||
<ul className="mb-1 flex gap-x-4 text-mineshaft-200">
|
||||
{[
|
||||
{
|
||||
type: ProjectType.SecretManager,
|
||||
label: "Secret Management",
|
||||
icon: "sliding-carousel"
|
||||
},
|
||||
{
|
||||
type: ProjectType.CertificateManager,
|
||||
label: "Certificate Management",
|
||||
icon: "note"
|
||||
},
|
||||
{
|
||||
type: ProjectType.KMS,
|
||||
label: "KMS",
|
||||
icon: "unlock"
|
||||
},
|
||||
{
|
||||
type: ProjectType.SSH,
|
||||
label: "SSH",
|
||||
icon: "verified"
|
||||
}
|
||||
].map(({ label, type, icon }, index) => (
|
||||
<li key={`project-template-${type}`}>
|
||||
<Link
|
||||
to={`/organization/${type}/settings`}
|
||||
className="mt-1 flex items-center gap-x-2 hover:text-mineshaft-100"
|
||||
>
|
||||
{index !== 0 && <span className="text-mineshaft-300">•</span>}
|
||||
<DotLottieReact
|
||||
src={`/lotties/${icon}.json`}
|
||||
loop
|
||||
className="mt-0.5 h-5 w-5"
|
||||
/>{" "}
|
||||
<span className="underline underline-offset-2">{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NoticeBannerV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { faHome } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
createFileRoute,
|
||||
linkOptions,
|
||||
stripSearchParams,
|
||||
} from '@tanstack/react-router'
|
||||
import { zodValidator } from '@tanstack/zod-adapter'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { SettingsPage } from './SettingsPage'
|
||||
|
||||
const SettingsPageQueryParams = z.object({
|
||||
selectedTab: z.string().catch("")
|
||||
});
|
||||
selectedTab: z.string().catch(''),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/settings/"
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/settings/',
|
||||
)({
|
||||
component: SettingsPage,
|
||||
validateSearch: zodValidator(SettingsPageQueryParams),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ selectedTab: "" })]
|
||||
middlewares: [stripSearchParams({ selectedTab: '' })],
|
||||
},
|
||||
context: () => ({
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Home",
|
||||
label: 'Home',
|
||||
icon: () => <FontAwesomeIcon icon={faHome} />,
|
||||
link: linkOptions({ to: "/" })
|
||||
link: linkOptions({ to: '/' }),
|
||||
},
|
||||
{
|
||||
label: "Settings"
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
label: 'Settings',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ export const SshHostPermissionConditions = ({ position = 0, isDisabled }: Props)
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} />
|
||||
<Input {...field} onChange={(e) => field.onChange(e.target.value.trim())} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useCreateSshHostGroup,
|
||||
useGetSshHostGroupById,
|
||||
useGetWorkspaceUsers,
|
||||
useListWorkspaceGroups,
|
||||
useListWorkspaceSshHostGroups,
|
||||
useUpdateSshHostGroup
|
||||
} from "@app/hooks/api";
|
||||
@@ -38,7 +39,14 @@ const schema = z
|
||||
loginMappings: z
|
||||
.object({
|
||||
loginUser: z.string().trim().min(1),
|
||||
allowedPrincipals: z.array(z.string().trim()).default([])
|
||||
allowedPrincipals: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(["user", "group"]),
|
||||
value: z.string().trim().min(1)
|
||||
})
|
||||
)
|
||||
.default([])
|
||||
})
|
||||
.array()
|
||||
.default([])
|
||||
@@ -49,9 +57,10 @@ export type FormData = z.infer<typeof schema>;
|
||||
|
||||
export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id);
|
||||
const projectId = currentWorkspace.id;
|
||||
const { data: sshHostGroups } = useListWorkspaceSshHostGroups(projectId);
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
|
||||
|
||||
const { data: sshHostGroup } = useGetSshHostGroupById(
|
||||
@@ -87,7 +96,16 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
name: sshHostGroup.name,
|
||||
loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: allowedPrincipals.usernames
|
||||
allowedPrincipals: [
|
||||
...(allowedPrincipals.usernames || []).map((username) => ({
|
||||
type: "user" as const,
|
||||
value: username
|
||||
})),
|
||||
...(allowedPrincipals.groups || []).map((group) => ({
|
||||
type: "group" as const,
|
||||
value: group
|
||||
}))
|
||||
]
|
||||
}))
|
||||
});
|
||||
|
||||
@@ -118,27 +136,35 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const transformedLoginMappings = loginMappings.map(({ loginUser, allowedPrincipals }) => {
|
||||
const usernames = allowedPrincipals
|
||||
.filter((p) => p.type === "user" && p.value)
|
||||
.map((p) => p.value);
|
||||
|
||||
const groupNames = allowedPrincipals
|
||||
.filter((p) => p.type === "group" && p.value)
|
||||
.map((p) => p.value);
|
||||
|
||||
return {
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames,
|
||||
groups: groupNames
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (sshHostGroup) {
|
||||
await updateMutateAsync({
|
||||
sshHostGroupId: sshHostGroup.id,
|
||||
name,
|
||||
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames: allowedPrincipals
|
||||
}
|
||||
}))
|
||||
loginMappings: transformedLoginMappings
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
projectId,
|
||||
name,
|
||||
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames: allowedPrincipals
|
||||
}
|
||||
}))
|
||||
loginMappings: transformedLoginMappings
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,6 +191,15 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const isPrincipalDuplicate = (
|
||||
mappingIndex: number,
|
||||
principalType: string,
|
||||
principalValue: string
|
||||
) => {
|
||||
const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || [];
|
||||
return principals.some((p) => p.type === principalType && p.value === principalValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.sshHostGroup?.isOpen}
|
||||
@@ -203,7 +238,10 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const newIndex = loginMappingsFormFields.fields.length;
|
||||
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
|
||||
loginMappingsFormFields.append({
|
||||
loginUser: "",
|
||||
allowedPrincipals: [{ type: "user", value: "" }]
|
||||
});
|
||||
setExpandedMappings((prev) => ({
|
||||
...prev,
|
||||
[newIndex]: true
|
||||
@@ -299,7 +337,10 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
|
||||
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
|
||||
setValue(`loginMappings.${i}.allowedPrincipals`, [
|
||||
...current,
|
||||
{ type: "user", value: "" }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Principal
|
||||
@@ -310,40 +351,69 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
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>
|
||||
{value.map((principal, principalIndex) => (
|
||||
<div
|
||||
key={`principal-${i + 1}-${principalIndex + 1}-${principal.type}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="mr-2">
|
||||
<Select
|
||||
className="w-24"
|
||||
value={principal.type}
|
||||
onValueChange={(newType) => {
|
||||
const newPrincipals = [...value];
|
||||
newPrincipals[principalIndex] = {
|
||||
type: newType as "user" | "group",
|
||||
value: ""
|
||||
};
|
||||
onChange(newPrincipals);
|
||||
}}
|
||||
>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={principal.value}
|
||||
onValueChange={(newValue) => {
|
||||
if (isPrincipalDuplicate(i, principal.type, newValue)) {
|
||||
createNotification({
|
||||
text: `This ${principal.type} is already added`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const newPrincipals = [...value];
|
||||
newPrincipals[principalIndex] = {
|
||||
type: principal.type as "user" | "group",
|
||||
value: newValue
|
||||
};
|
||||
onChange(newPrincipals);
|
||||
}}
|
||||
placeholder={`Select a ${principal.type}`}
|
||||
className="w-full"
|
||||
>
|
||||
{principal.type === "user"
|
||||
? members.map((member) => (
|
||||
<SelectItem
|
||||
key={member.user.id}
|
||||
value={member.user.username}
|
||||
>
|
||||
{member.user.username}
|
||||
</SelectItem>
|
||||
))
|
||||
: groups.map((group) => (
|
||||
<SelectItem
|
||||
key={group.group.slug}
|
||||
value={group.group.slug}
|
||||
>
|
||||
{group.group.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-10 justify-center">
|
||||
<IconButton
|
||||
size="sm"
|
||||
ariaLabel="delete principal"
|
||||
@@ -353,14 +423,14 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const newPrincipals = value.filter(
|
||||
(_, idx) => idx !== principalIndex
|
||||
);
|
||||
onChange(newPrincipals);
|
||||
onChange(newPrincipals.length ? newPrincipals : []);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error && <span className="text-sm text-red-500">{error.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
@@ -56,22 +56,38 @@ export const SshHostGroupsSection = () => {
|
||||
<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">Host Groups</p>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SshHostGroups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="button"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddSshHostGroupModal()}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/ssh/host-groups"
|
||||
>
|
||||
<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.SshHostGroups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="button"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddSshHostGroupModal()}
|
||||
isDisabled={!isAllowed}
|
||||
className="ml-4"
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<SshHostGroupsTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faEllipsis,
|
||||
faPencil,
|
||||
faServer,
|
||||
faTrash,
|
||||
faUser,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -77,9 +85,40 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
group.loginMappings.map(({ loginUser, allowedPrincipals }) => (
|
||||
<div key={`${group.id}-${loginUser}`} className="mb-2">
|
||||
<div className="text-mineshaft-200">{loginUser}</div>
|
||||
{allowedPrincipals.usernames.map((username) => (
|
||||
<div key={`${group.id}-${loginUser}-${username}`} className="ml-4">
|
||||
└─ {username}
|
||||
{allowedPrincipals.usernames?.map((username) => (
|
||||
<div
|
||||
key={`${loginUser}-${username}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-400">└─</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="text-xs text-yellow/80"
|
||||
/>
|
||||
<span>{username}</span>
|
||||
<Badge variant="primary">user</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{allowedPrincipals.groups?.map((allowedGroup) => (
|
||||
<div
|
||||
key={`${loginUser}-${allowedGroup}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-400">└─</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faUsers}
|
||||
className="text-xs text-green/80"
|
||||
/>
|
||||
<span>{allowedGroup}</span>
|
||||
<Badge variant="success">group</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useCreateSshHost,
|
||||
useGetSshHostById,
|
||||
useGetWorkspaceUsers,
|
||||
useListWorkspaceGroups,
|
||||
useListWorkspaceSshHosts,
|
||||
useUpdateSshHost
|
||||
} from "@app/hooks/api";
|
||||
@@ -49,7 +50,14 @@ const schema = z
|
||||
loginMappings: z
|
||||
.object({
|
||||
loginUser: z.string().trim().min(1),
|
||||
allowedPrincipals: z.array(z.string().trim()).default([]),
|
||||
allowedPrincipals: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(["user", "group"]),
|
||||
value: z.string().trim().min(1)
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
source: z.nativeEnum(LoginMappingSource)
|
||||
})
|
||||
.array()
|
||||
@@ -64,6 +72,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id);
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
|
||||
|
||||
const { data: sshHost } = useGetSshHostById(
|
||||
@@ -103,7 +112,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
userCertTtl: sshHost.userCertTtl,
|
||||
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: allowedPrincipals.usernames,
|
||||
allowedPrincipals: [
|
||||
...(allowedPrincipals.usernames || []).map((username) => ({
|
||||
type: "user" as const,
|
||||
value: username
|
||||
})),
|
||||
...(allowedPrincipals.groups || []).map((group) => ({
|
||||
type: "group" as const,
|
||||
value: group
|
||||
}))
|
||||
],
|
||||
source
|
||||
}))
|
||||
});
|
||||
@@ -159,18 +177,31 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const transformedLoginMappings = hostLoginMappings.map(({ loginUser, allowedPrincipals }) => {
|
||||
const usernames = allowedPrincipals
|
||||
.filter((p) => p.type === "user" && p.value)
|
||||
.map((p) => p.value);
|
||||
|
||||
const groupNames = allowedPrincipals
|
||||
.filter((p) => p.type === "group" && p.value)
|
||||
.map((p) => p.value);
|
||||
|
||||
return {
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames,
|
||||
groups: groupNames
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (sshHost) {
|
||||
await updateMutateAsync({
|
||||
sshHostId: sshHost.id,
|
||||
hostname,
|
||||
alias: trimmedAlias,
|
||||
userCertTtl,
|
||||
loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames: allowedPrincipals
|
||||
}
|
||||
}))
|
||||
loginMappings: transformedLoginMappings
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
@@ -178,12 +209,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
hostname,
|
||||
alias: trimmedAlias,
|
||||
userCertTtl,
|
||||
loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames: allowedPrincipals
|
||||
}
|
||||
}))
|
||||
loginMappings: transformedLoginMappings
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,6 +236,15 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const isPrincipalDuplicate = (
|
||||
mappingIndex: number,
|
||||
principalType: string,
|
||||
principalValue: string
|
||||
) => {
|
||||
const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || [];
|
||||
return principals.some((p) => p.type === principalType && p.value === principalValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.sshHost?.isOpen}
|
||||
@@ -275,7 +310,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const newIndex = loginMappingsFormFields.fields.length;
|
||||
loginMappingsFormFields.append({
|
||||
loginUser: "",
|
||||
allowedPrincipals: [""],
|
||||
allowedPrincipals: [],
|
||||
source: LoginMappingSource.HOST
|
||||
});
|
||||
setExpandedMappings((prev) => ({
|
||||
@@ -397,7 +432,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
onClick={() => {
|
||||
const current =
|
||||
getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
|
||||
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
|
||||
setValue(`loginMappings.${i}.allowedPrincipals`, [
|
||||
...current,
|
||||
{ type: "user", value: "" }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Principal
|
||||
@@ -409,50 +447,82 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
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 (
|
||||
loginMappingsFormFields.fields[i].source ===
|
||||
LoginMappingSource.HOST_GROUP
|
||||
)
|
||||
return;
|
||||
|
||||
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"
|
||||
isDisabled={
|
||||
{value.map((principal, principalIndex) => (
|
||||
<div
|
||||
key={`principal-${i + 1}-${principalIndex + 1}-${principal.type}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="mr-2">
|
||||
<Select
|
||||
className="w-24"
|
||||
value={principal.type}
|
||||
onValueChange={(newType) => {
|
||||
const newPrincipals = [...value];
|
||||
newPrincipals[principalIndex] = {
|
||||
type: newType as "user" | "group",
|
||||
value: ""
|
||||
};
|
||||
onChange(newPrincipals);
|
||||
}}
|
||||
isDisabled={
|
||||
loginMappingsFormFields.fields[i].source ===
|
||||
LoginMappingSource.HOST_GROUP
|
||||
}
|
||||
>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={principal.value}
|
||||
onValueChange={(newValue) => {
|
||||
if (
|
||||
loginMappingsFormFields.fields[i].source ===
|
||||
LoginMappingSource.HOST_GROUP
|
||||
)
|
||||
return;
|
||||
if (isPrincipalDuplicate(i, principal.type, newValue)) {
|
||||
createNotification({
|
||||
text: `This ${principal.type} is already added`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
>
|
||||
{members.map((member) => (
|
||||
<SelectItem
|
||||
key={member.user.id}
|
||||
value={member.user.username}
|
||||
>
|
||||
{member.user.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
const newPrincipals = [...value];
|
||||
newPrincipals[principalIndex] = {
|
||||
type: principal.type,
|
||||
value: newValue
|
||||
};
|
||||
onChange(newPrincipals);
|
||||
}}
|
||||
placeholder={`Select a ${principal.type}`}
|
||||
className="w-full"
|
||||
isDisabled={
|
||||
loginMappingsFormFields.fields[i].source ===
|
||||
LoginMappingSource.HOST_GROUP
|
||||
}
|
||||
>
|
||||
{principal.type === "user"
|
||||
? members.map((member) => (
|
||||
<SelectItem
|
||||
key={member.user.id}
|
||||
value={member.user.username}
|
||||
>
|
||||
{member.user.username}
|
||||
</SelectItem>
|
||||
))
|
||||
: groups.map((group) => (
|
||||
<SelectItem
|
||||
key={group.group.slug}
|
||||
value={group.group.slug}
|
||||
>
|
||||
{group.group.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-10 justify-center">
|
||||
<IconButton
|
||||
size="sm"
|
||||
ariaLabel="delete principal"
|
||||
@@ -468,7 +538,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const newPrincipals = value.filter(
|
||||
(_, idx) => idx !== principalIndex
|
||||
);
|
||||
onChange(newPrincipals);
|
||||
onChange([...newPrincipals]);
|
||||
}}
|
||||
isDisabled={
|
||||
loginMappingsFormFields.fields[i].source ===
|
||||
@@ -478,8 +548,8 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error && <span className="text-sm text-red-500">{error.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SshHostsSection = () => {
|
||||
<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">
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
faEllipsis,
|
||||
faPencil,
|
||||
faServer,
|
||||
faTrash
|
||||
faTrash,
|
||||
faUser,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import FileSaver from "file-saver";
|
||||
@@ -12,6 +14,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -101,43 +104,53 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
|
||||
|
||||
const hostLoginUserToPrincipals = hostMappings.reduce(
|
||||
(acc, { loginUser, allowedPrincipals }) => {
|
||||
acc[loginUser] = new Set(allowedPrincipals.usernames);
|
||||
acc[loginUser] = {
|
||||
users: new Set(allowedPrincipals.usernames),
|
||||
groups: new Set(allowedPrincipals.groups)
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Set<string>>
|
||||
{} as Record<string, { users: Set<string>; groups: Set<string> }>
|
||||
);
|
||||
|
||||
const entriesFromHost = hostMappings.map(
|
||||
({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
source: LoginMappingSource.HOST,
|
||||
usernames: allowedPrincipals.usernames
|
||||
users: allowedPrincipals.usernames,
|
||||
groups: allowedPrincipals.groups
|
||||
})
|
||||
);
|
||||
|
||||
const entriesFromGroup = groupMappings
|
||||
.map(({ loginUser, allowedPrincipals }) => {
|
||||
const existing = hostLoginUserToPrincipals[loginUser] || new Set();
|
||||
const filteredUsernames = allowedPrincipals.usernames.filter(
|
||||
(u) => !existing.has(u)
|
||||
const existing = hostLoginUserToPrincipals[loginUser] || {};
|
||||
const filteredUsernames = allowedPrincipals.usernames?.filter(
|
||||
(u) => !existing.users?.has(u)
|
||||
);
|
||||
return filteredUsernames.length > 0
|
||||
const filteredGroups = allowedPrincipals.groups?.filter(
|
||||
(g) => !existing.groups?.has(g)
|
||||
);
|
||||
return ((filteredGroups?.length || filteredUsernames?.length) ?? 0) >
|
||||
0
|
||||
? {
|
||||
loginUser,
|
||||
source: LoginMappingSource.HOST_GROUP,
|
||||
usernames: filteredUsernames
|
||||
users: filteredUsernames,
|
||||
groups: filteredGroups
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
loginUser: string;
|
||||
source: LoginMappingSource;
|
||||
usernames: string[];
|
||||
users: string[];
|
||||
groups: string[];
|
||||
}[];
|
||||
|
||||
return [...entriesFromHost, ...entriesFromGroup]
|
||||
.sort((a, b) => a.loginUser.localeCompare(b.loginUser))
|
||||
.map(({ loginUser, usernames, source }) => (
|
||||
.map(({ loginUser, users, groups, source }) => (
|
||||
<div key={`${host.id}-${loginUser}-${source}`} className="mb-2">
|
||||
<div className="text-mineshaft-200">
|
||||
{loginUser}
|
||||
@@ -147,12 +160,40 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{usernames.map((username) => (
|
||||
{users?.map((username) => (
|
||||
<div
|
||||
key={`${host.id}-${loginUser}-${source}-${username}`}
|
||||
className="ml-4"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
└─ {username}
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-400">└─</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="text-xs text-yellow/80"
|
||||
/>
|
||||
<span>{username}</span>
|
||||
<Badge variant="primary">user</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{groups?.map((group) => (
|
||||
<div
|
||||
key={`${host.id}-${loginUser}-${source}-${group}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-400">└─</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faUsers}
|
||||
className="text-xs text-green/80"
|
||||
/>
|
||||
<span>{group}</span>
|
||||
<Badge variant="success">group</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user