Merge remote-tracking branch 'origin/main' into feat/PKI-29

This commit is contained in:
Carlos Monastyrski
2025-11-20 16:10:49 -03:00
171 changed files with 5153 additions and 1991 deletions
@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "A project with this name already exists.",
"no-mobile": " To use Infisical, please log in through a device with larger dimensions. ",
"email": "Email",
"password": "Password",
"first-name": "First Name",
@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Ya existe un proyecto con este nombre.",
"no-mobile": "Para usar Infisical, inicia sesión con un dispositivo de mayores dimesiones.",
"email": "Correo electrónico",
"password": "Contraseña",
"first-name": "Nombre",
@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Un projet avec ce nom existe déjà.",
"no-mobile": " Pour utiliser Infisical, veuillez vous connecter avec un appareil avec des dimensions plus grandes. ",
"email": "Email",
"password": "Mot de passe",
"first-name": "Prénom",
@@ -30,7 +30,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "동일한 이름을 가진 프로젝트가 이미 존재해요.",
"no-mobile": " Infisical을 사용하려면, 큰 화면을 가진 디바이스로 로그인하여 주세요.",
"email": "메일",
"password": "비밀번호",
"first-name": "이름",
@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Já exite um projeto com este nome.",
"no-mobile": "Para usar o Infisical, faça o login através de um dispositivo com dimensões maiores.",
"email": "Email",
"password": "Senha",
"first-name": "Primeiro Nome",
@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Bu isimle bir proje zaten mevcut.",
"no-mobile": " Infisical'ı kullanmak için, lütfen daha büyük boyutlara sahip bir cihaz üzerinden giriş yapın. ",
"email": "Email",
"password": "Şifre",
"first-name": "Adınız",
@@ -76,8 +76,8 @@ export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) =
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-medium text-mineshaft-100">Project Overview</h2>
<div className="justify-betweens mb-8 flex flex-wrap gap-2">
<h2 className="flex-1 text-xl font-medium text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<Button
variant="outline_bg"
+20 -18
View File
@@ -262,24 +262,25 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.UPDATE_IDENTITY_PROJECT_MEMBERSHIP]: "Update Identity Project Membership",
[EventType.DELETE_IDENTITY_PROJECT_MEMBERSHIP]: "Delete Identity Project Membership",
[EventType.PAM_SESSION_START]: "PAM Session Start",
[EventType.PAM_SESSION_LOGS_UPDATE]: "PAM Session Logs Update",
[EventType.PAM_SESSION_END]: "PAM Session End",
[EventType.PAM_SESSION_GET]: "PAM Session Get",
[EventType.PAM_SESSION_LIST]: "PAM Session List",
[EventType.PAM_FOLDER_CREATE]: "PAM Folder Create",
[EventType.PAM_FOLDER_UPDATE]: "PAM Folder Update",
[EventType.PAM_FOLDER_DELETE]: "PAM Folder Delete",
[EventType.PAM_ACCOUNT_LIST]: "PAM Account List",
[EventType.PAM_ACCOUNT_ACCESS]: "PAM Account Access",
[EventType.PAM_ACCOUNT_CREATE]: "PAM Account Create",
[EventType.PAM_ACCOUNT_UPDATE]: "PAM Account Update",
[EventType.PAM_ACCOUNT_DELETE]: "PAM Account Delete",
[EventType.PAM_RESOURCE_LIST]: "PAM Resource List",
[EventType.PAM_RESOURCE_GET]: "PAM Resource Get",
[EventType.PAM_RESOURCE_CREATE]: "PAM Resource Create",
[EventType.PAM_RESOURCE_UPDATE]: "PAM Resource Update",
[EventType.PAM_RESOURCE_DELETE]: "PAM Resource Delete",
[EventType.PAM_SESSION_CREDENTIALS_GET]: "Get PAM Session Credentials",
[EventType.PAM_SESSION_START]: "Start PAM Session",
[EventType.PAM_SESSION_LOGS_UPDATE]: "Update PAM Session Logs",
[EventType.PAM_SESSION_END]: "End PAM Session",
[EventType.PAM_SESSION_GET]: "Get PAM Session",
[EventType.PAM_SESSION_LIST]: "List PAM Sessions",
[EventType.PAM_FOLDER_CREATE]: "Create PAM Folder",
[EventType.PAM_FOLDER_UPDATE]: "Update PAM Folder",
[EventType.PAM_FOLDER_DELETE]: "Delete PAM Folder",
[EventType.PAM_ACCOUNT_LIST]: "List PAM Accounts",
[EventType.PAM_ACCOUNT_ACCESS]: "Access PAM Account",
[EventType.PAM_ACCOUNT_CREATE]: "Create PAM Account",
[EventType.PAM_ACCOUNT_UPDATE]: "Update PAM Account",
[EventType.PAM_ACCOUNT_DELETE]: "Delete PAM Account",
[EventType.PAM_RESOURCE_LIST]: "List PAM Resources",
[EventType.PAM_RESOURCE_GET]: "Get PAM Resource",
[EventType.PAM_RESOURCE_CREATE]: "Create PAM Resource",
[EventType.PAM_RESOURCE_UPDATE]: "Update PAM Resource",
[EventType.PAM_RESOURCE_DELETE]: "Delete PAM Resource",
[EventType.CREATE_CERTIFICATE_PROFILE]: "Create Certificate Profile",
[EventType.UPDATE_CERTIFICATE_PROFILE]: "Update Certificate Profile",
@@ -314,6 +315,7 @@ const sharedProjectEvents = [
export const projectToEventsMap: Partial<Record<ProjectType, EventType[]>> = {
[ProjectType.PAM]: [
...sharedProjectEvents,
EventType.PAM_SESSION_CREDENTIALS_GET,
EventType.PAM_SESSION_START,
EventType.PAM_SESSION_LOGS_UPDATE,
EventType.PAM_SESSION_END,
@@ -254,6 +254,7 @@ export enum EventType {
UPDATE_IDENTITY_PROJECT_MEMBERSHIP = "update-identity-project-membership",
DELETE_IDENTITY_PROJECT_MEMBERSHIP = "delete-identity-project-membership",
PAM_SESSION_CREDENTIALS_GET = "pam-session-credentials-get",
PAM_SESSION_START = "pam-session-start",
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
PAM_SESSION_END = "pam-session-end",
@@ -27,9 +27,18 @@ export const useDeleteCert = () => {
);
return certificate;
},
onSuccess: (_, { projectSlug }) => {
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
queryKey: ["certificate-profiles", "list"]
});
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.allProjectCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectId)
});
}
});
@@ -49,16 +58,18 @@ export const useRevokeCert = () => {
);
return certificate;
},
onSuccess: (_, { projectSlug }) => {
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
queryKey: ["certificate-profiles", "list"]
});
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
queryKey: projectKeys.allProjectCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectId)
});
}
});
+2 -2
View File
@@ -24,12 +24,12 @@ export type TCertificate = {
};
export type TDeleteCertDTO = {
projectSlug: string;
projectId: string;
serialNumber: string;
};
export type TRevokeCertDTO = {
projectSlug: string;
projectId: string;
serialNumber: string;
revocationReason: string;
};
+1 -3
View File
@@ -840,9 +840,7 @@ export type CreateTokenIdentityTokenAuthDTO = {
export type CreateTokenIdentityTokenAuthRes = {
accessToken: string;
tokenType: string;
expiresIn: number;
accessTokenMaxTTL: number;
tokenData: IdentityAccessToken;
};
export type UpdateTokenIdentityTokenAuthDTO = {
+16
View File
@@ -1,3 +1,4 @@
// Resources
export enum PamResourceType {
Postgres = "postgres",
MySQL = "mysql",
@@ -18,9 +19,24 @@ export enum PamResourceType {
DynamoDB = "dynamodb"
}
export enum PamResourceOrderBy {
Name = "name"
}
// Sessions
export enum PamSessionStatus {
Starting = "starting",
Active = "active",
Ended = "ended",
Terminated = "terminated"
}
// Accounts
export enum PamAccountOrderBy {
Name = "name"
}
export enum PamAccountView {
Flat = "flat",
Nested = "nested"
}
+9 -9
View File
@@ -31,7 +31,7 @@ export const useCreatePamResource = () => {
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
}
});
};
@@ -48,7 +48,7 @@ export const useUpdatePamResource = () => {
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
}
});
};
@@ -64,7 +64,7 @@ export const useDeletePamResource = () => {
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
}
});
};
@@ -82,7 +82,7 @@ export const useCreatePamAccount = () => {
return data.account;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
}
});
};
@@ -99,7 +99,7 @@ export const useUpdatePamAccount = () => {
return data.account;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
}
});
};
@@ -115,7 +115,7 @@ export const useDeletePamAccount = () => {
return data.account;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
}
});
};
@@ -130,7 +130,7 @@ export const useCreatePamFolder = () => {
return data.folder;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
}
});
};
@@ -147,7 +147,7 @@ export const useUpdatePamFolder = () => {
return data.folder;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
}
});
};
@@ -163,7 +163,7 @@ export const useDeletePamFolder = () => {
return data.folder;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
}
});
};
+50 -20
View File
@@ -4,7 +4,14 @@ import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options";
import { PamResourceType } from "./enums";
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
import {
TListPamAccountsDTO,
TListPamResourcesDTO,
TPamAccount,
TPamFolder,
TPamResource,
TPamSession
} from "./types";
export const pamKeys = {
all: ["pam"] as const,
@@ -12,14 +19,24 @@ export const pamKeys = {
account: () => [...pamKeys.all, "account"] as const,
session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
listResources: ({ projectId, ...params }: TListPamResourcesDTO) => [
...pamKeys.resource(),
"list",
projectId,
params
],
getResource: (resourceType: string, resourceId: string) => [
...pamKeys.resource(),
"get",
resourceType,
resourceId
],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
listAccounts: ({ projectId, ...params }: TListPamAccountsDTO) => [
...pamKeys.account(),
"list",
projectId,
params
],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
};
@@ -49,28 +66,33 @@ export const useListPamResourceOptions = (
});
};
type TListPamResourcesResponse = {
resources: TPamResource[];
totalCount: number;
};
export const useListPamResources = (
projectId: string,
params: TListPamResourcesDTO,
options?: Omit<
UseQueryOptions<
TPamResource[],
TListPamResourcesResponse,
unknown,
TPamResource[],
TListPamResourcesResponse,
ReturnType<typeof pamKeys.listResources>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listResources(projectId),
queryKey: pamKeys.listResources(params),
queryFn: async () => {
const { data } = await apiRequest.get<{ resources: TPamResource[] }>(
"/api/v1/pam/resources",
{ params: { projectId } }
);
const { data } = await apiRequest.get<TListPamResourcesResponse>("/api/v1/pam/resources", {
params
});
return data.resources;
return data;
},
placeholderData: (prev) => prev,
...options
});
};
@@ -98,28 +120,36 @@ export const useGetPamResourceById = (
};
// Accounts
type TListPamAccountsResponse = {
accounts: TPamAccount[];
folders: TPamFolder[];
totalCount: number;
folderId?: string;
folderPaths: Record<string, string>;
};
export const useListPamAccounts = (
projectId: string,
params: TListPamAccountsDTO,
options?: Omit<
UseQueryOptions<
{ accounts: TPamAccount[]; folders: TPamFolder[] },
TListPamAccountsResponse,
unknown,
{ accounts: TPamAccount[]; folders: TPamFolder[] },
TListPamAccountsResponse,
ReturnType<typeof pamKeys.listAccounts>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listAccounts(projectId),
queryKey: pamKeys.listAccounts(params),
queryFn: async () => {
const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>(
"/api/v1/pam/accounts",
{ params: { projectId } }
);
const { data } = await apiRequest.get<TListPamAccountsResponse>("/api/v1/pam/accounts", {
params
});
return data;
},
placeholderData: (prev) => prev,
...options
});
};
+51 -8
View File
@@ -1,13 +1,22 @@
import { PamResourceType, PamSessionStatus } from "../enums";
import { OrderByDirection } from "../../generic/types";
import {
PamAccountOrderBy,
PamAccountView,
PamResourceOrderBy,
PamResourceType,
PamSessionStatus
} from "../enums";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource";
export * from "./mysql-resource";
export * from "./postgres-resource";
export * from "./ssh-resource";
export type TPamResource = TPostgresResource | TMySQLResource;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamAccount = TPostgresAccount | TMySQLAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
export type TPamFolder = {
id: string;
@@ -19,6 +28,22 @@ export type TPamFolder = {
updatedAt: string;
};
// Session log types
export type TPamCommandLog = {
input: string;
output: string;
timestamp: string;
};
export type TTerminalEvent = {
timestamp: string;
eventType: "input" | "output" | "resize" | "error";
data: string; // Base64 encoded binary data
elapsedTime: number; // Seconds since session start (for replay)
};
export type TPamSessionLog = TPamCommandLog | TTerminalEvent;
export type TPamSession = {
id: string;
projectId: string;
@@ -37,14 +62,20 @@ export type TPamSession = {
endedAt?: string | null;
createdAt: string;
updatedAt: string;
commandLogs: {
input: string;
output: string;
timestamp: string;
}[];
logs: TPamSessionLog[];
};
// Resource DTOs
export type TListPamResourcesDTO = {
projectId: string;
offset?: number;
limit?: number;
orderBy?: PamResourceOrderBy;
orderDirection?: OrderByDirection;
search?: string;
filterResourceTypes?: string;
};
export type TCreatePamResourceDTO = Pick<
TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
@@ -63,6 +94,18 @@ export type TDeletePamResourceDTO = {
};
// Account DTOs
export type TListPamAccountsDTO = {
projectId: string;
accountPath?: string | null;
accountView?: PamAccountView;
offset?: number;
limit?: number;
orderBy?: PamAccountOrderBy;
orderDirection?: OrderByDirection;
search?: string;
filterResourceIds?: string;
};
export type TCreatePamAccountDTO = Pick<
TPamAccount,
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
@@ -0,0 +1,47 @@
import { PamResourceType } from "../enums";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
export enum SSHAuthMethod {
Password = "password",
PublicKey = "public-key",
Certificate = "certificate"
}
export type TSSHConnectionDetails = {
host: string;
port: number;
};
export type TSSHPasswordCredentials = {
authMethod: SSHAuthMethod.Password;
username: string;
password: string;
};
export type TSSHPublicKeyCredentials = {
authMethod: SSHAuthMethod.PublicKey;
username: string;
privateKey: string;
};
export type TSSHCertificateCredentials = {
authMethod: SSHAuthMethod.Certificate;
username: string;
};
export type TSSHCredentials =
| TSSHPasswordCredentials
| TSSHPublicKeyCredentials
| TSSHCertificateCredentials;
// Resources
export type TSSHResource = TBasePamResource & { resourceType: PamResourceType.SSH } & {
connectionDetails: TSSHConnectionDetails;
rotationAccountCredentials?: TSSHCredentials | null;
};
// Accounts
export type TSSHAccount = TBasePamAccount & {
credentials: TSSHCredentials;
};
@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet } from "@tanstack/react-router";
import { Banner } from "@app/components/page-frames/Banner";
@@ -15,7 +12,6 @@ import { InsecureConnectionBanner } from "../OrganizationLayout/components/Insec
import { AdminNavBar } from "./AdminNavBar";
export const AdminLayout = () => {
const { t } = useTranslation();
const { config } = useServerConfig();
const { data: serverDetails, isLoading } = useFetchServerStatus();
const { subscription } = useSubscription();
@@ -26,7 +22,7 @@ export const AdminLayout = () => {
<>
<Banner />
<div
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
className={`dark ${containerHeight} flex w-full flex-col overflow-x-hidden bg-bunker-800 transition-all`}
>
<Navbar />
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
@@ -40,12 +36,6 @@ export const AdminLayout = () => {
</div>
</div>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
<Banner />
</>
);
+1 -1
View File
@@ -13,7 +13,7 @@ export const KmsLayout = () => {
const location = useLocation();
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -27,8 +24,6 @@ export const OrganizationLayout = () => {
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
const { t } = useTranslation();
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
const { data: serverDetails, isLoading } = useFetchServerStatus();
@@ -38,7 +33,7 @@ export const OrganizationLayout = () => {
<>
<Banner />
<div
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
className={`dark ${containerHeight} flex w-full flex-col overflow-x-hidden bg-bunker-800 transition-all`}
>
<Navbar />
<div className="flex grow flex-col overflow-y-hidden">
@@ -61,12 +56,6 @@ export const OrganizationLayout = () => {
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}
/>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
<Banner />
</>
);
@@ -16,15 +16,19 @@ import {
faSignOut,
faToolbox,
faUser,
faUserCog,
faUserPlus,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { UserPlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import SecurityClient from "@app/components/utilities/SecurityClient";
import {
BreadcrumbContainer,
@@ -44,7 +48,13 @@ import {
} from "@app/components/v2";
import { Badge, InstanceIcon, OrgIcon, SubOrgIcon } from "@app/components/v3";
import { envConfig } from "@app/config/env";
import { useOrganization, useSubscription, useUser } from "@app/context";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription,
useUser
} from "@app/context";
import { isInfisicalCloud } from "@app/helpers/platform";
import { useToggle } from "@app/hooks";
import {
@@ -294,7 +304,7 @@ export const Navbar = () => {
</>
) : (
<>
<div className="flex items-center overflow-hidden">
<div className="flex min-w-12 items-center overflow-hidden">
<DropdownMenu modal={false} open={isOrgSelectOpen} onOpenChange={setIsOrgSelectOpen}>
<div className="group flex cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100 hover:text-primary">
<Badge
@@ -324,7 +334,7 @@ export const Navbar = () => {
<span>{currentOrg?.name}</span>
</button>
</Badge>
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
<div className="mr-1 hidden rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline! md:inline-block">
{getPlan(subscription)}
</div>
{subscription.cardDeclined && (
@@ -468,7 +478,7 @@ export const Navbar = () => {
className={twMerge(
"gap-x-1.5 text-sm",
!isOrgScope &&
"bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-sub-org"
"min-w-6 bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-sub-org"
)}
>
<Link to="/organization/projects">
@@ -567,15 +577,36 @@ export const Navbar = () => {
</Button>
</Tooltip>
)}
{user.superAdmin && !location.pathname.startsWith("/admin") && (
<Link
className="mr-2 flex items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/admin"
>
<InstanceIcon className="mr-2 inline-block size-3.5" />
Server Console
</Link>
)}
{/* eslint-disable-next-line no-nested-ternary */}
{!location.pathname.startsWith("/admin") ? (
user.superAdmin ? (
<Link
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/admin"
>
<InstanceIcon className="inline-block size-3.5" />
<span className="ml-2 hidden md:inline-block">Server Console</span>
</Link>
) : (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) =>
isAllowed ? (
<Link
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/organization/access-management"
search={{
selectedTab: "members",
action: "invite-members"
}}
>
<UserPlusIcon className="inline-block size-3.5" />
<span className="ml-2 hidden md:inline-block">Invite Members</span>
</Link>
) : null
}
</OrgPermissionCan>
)
) : null}
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<div className="rounded-l-md border border-r-0 border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
@@ -657,8 +688,27 @@ export const Navbar = () => {
</div>
</div>
<Link to="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
<DropdownMenuItem icon={<FontAwesomeIcon icon={faUserCog} />}>
Personal Settings
</DropdownMenuItem>
</Link>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) =>
isAllowed ? (
<Link
to="/organization/access-management"
search={{
selectedTab: "members",
action: "invite-members"
}}
>
<DropdownMenuItem icon={<FontAwesomeIcon icon={faUserPlus} />}>
Invite Members
</DropdownMenuItem>
</Link>
) : null
}
</OrgPermissionCan>
<a
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
@@ -21,7 +21,7 @@ export const OrgNavBar = ({ isHidden }: Props) => {
return (
<>
{!isHidden && (
<div className="dark hidden w-full flex-col overflow-x-hidden border-b border-mineshaft-600 bg-mineshaft-900 px-4 md:flex">
<div className="dark flex w-full flex-col overflow-x-hidden border-b border-mineshaft-600 bg-mineshaft-900 px-4">
<motion.div
key="menu-org-items"
initial={{ x: -150 }}
+1 -1
View File
@@ -28,7 +28,7 @@ export const PamLayout = () => {
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
@@ -1,29 +1,16 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet } from "@tanstack/react-router";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
export const PersonalSettingsLayout = () => {
const { t } = useTranslation();
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden bg-bunker-800 md:flex">
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4 dark:scheme-dark">
<Outlet />
</main>
</div>
<div className="dark flex h-screen w-full flex-col overflow-x-hidden bg-bunker-800">
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4 dark:scheme-dark">
<Outlet />
</main>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
</>
</div>
);
};
@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
@@ -17,7 +14,6 @@ export const PkiManagerLayout = () => {
const { currentProject } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission();
const { subscription } = useSubscription();
const { t } = useTranslation();
const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || "");
const { data: templatesData } = useListWorkspaceCertificateTemplates({
@@ -32,147 +28,137 @@ export const PkiManagerLayout = () => {
const location = useLocation();
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/cert-management/$projectId/policies"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive || location.pathname.match(/\/ca\//) ? "selected" : ""}>
Certificate Authorities
</Tab>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/alerting"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Alerting</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>App Connections</Tab>
)}
</Link>
{showLegacySection && (
<>
{(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Subscribers (Legacy)</Tab>
)}
</Link>
)}
{(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link
to="/projects/cert-management/$projectId/certificate-templates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Certificate Templates (Legacy)
</Tab>
)}
</Link>
)}
</>
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/cert-management/$projectId/policies"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive || location.pathname.match(/\/ca\//) ? "selected" : ""}>
Certificate Authorities
</Tab>
)}
<Link
to="/projects/cert-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
</Link>
<Link
to="/projects/cert-management/$projectId/alerting"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Alerting</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>App Connections</Tab>}
</Link>
{showLegacySection && (
<>
{(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId: currentProject.id
}}
>
Access Control
</Tab>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Subscribers (Legacy)</Tab>
)}
</Link>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
{(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link
to="/projects/cert-management/$projectId/certificate-templates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Certificate Templates (Legacy)
</Tab>
)}
</Link>
)}
</>
)}
<Link
to="/projects/cert-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
</>
</div>
);
};
@@ -106,7 +106,9 @@ export const ProjectSelect = () => {
</p>
<Badge variant="project">
<ProjectIcon />
{currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"}
<span className="hidden sm:inline-block">
{currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"}
</span>
</Badge>
</Link>
<DropdownMenuTrigger asChild>
@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
@@ -20,7 +17,6 @@ export const SecretManagerLayout = () => {
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
const { t } = useTranslation();
const projectSlug = currentProject?.slug || "";
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
@@ -42,141 +38,131 @@ export const SecretManagerLayout = () => {
(secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentProject.id,
...(currentProject.environments.length
? { envSlug: currentProject.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/secrets\/|\/commits\//)
? "selected"
: ""
}
>
Overview
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/approval"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Approvals
{Boolean(
secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="warning" isSquare className="ml-1.5">
{pendingRequestsCount}
</Badge>
)}
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
{Boolean(secretRotations?.length) && (
<Link
to="/projects/secret-management/$projectId/secret-rotation"
params={{
projectId: currentProject.id
}}
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentProject.id,
...(currentProject.environments.length
? { envSlug: currentProject.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/secrets\/|\/commits\//)
? "selected"
: ""
}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Secret Rotations</Tab>
)}
</Link>
Overview
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/approval"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Approvals
{Boolean(
secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="warning" isSquare className="ml-1.5">
{pendingRequestsCount}
</Badge>
)}
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
{Boolean(secretRotations?.length) && (
<Link
to="/projects/secret-management/$projectId/app-connections"
to="/projects/secret-management/$projectId/secret-rotation"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>App Connections</Tab>
<Tab value={isActive ? "selected" : ""}>Secret Rotations</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/secret-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
)}
<Link
to="/projects/secret-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>App Connections</Tab>}
</Link>
<Link
to="/projects/secret-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/secret-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
</>
</div>
);
};
@@ -36,7 +36,7 @@ export const SecretScanningLayout = () => {
);
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
+1 -1
View File
@@ -18,7 +18,7 @@ export const SshLayout = () => {
const location = useLocation();
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
@@ -368,7 +368,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
name="type"
defaultValue={CaType.ACME}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<FormControl label="CA Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
@@ -53,7 +53,7 @@ export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props)
const { serialNumber } = popUp.revokeCertificate.data as { serialNumber: string };
await revokeCertificate({
projectSlug: currentProject.slug,
projectId: currentProject.id,
serialNumber,
revocationReason
});
@@ -42,7 +42,10 @@ export const CertificatesSection = () => {
const onRemoveCertificateSubmit = async (serialNumber: string) => {
if (!currentProject?.slug) return;
await deleteCert({ serialNumber, projectSlug: currentProject.slug });
await deleteCert({
serialNumber,
projectId: currentProject.id
});
createNotification({
text: "Successfully deleted certificate",
@@ -49,7 +49,7 @@ export const OrgGroupsSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Groups</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/groups" />
@@ -97,7 +97,7 @@ export const IdentitySection = withPermission(
return (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex w-full items-center gap-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/machine-identities" />
@@ -139,7 +139,7 @@ export const IdentitySection = withPermission(
</div>
{/* Identity Auth Templates Section */}
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Identity Auth Templates</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/auth-templates" />
@@ -1,7 +1,8 @@
import { useState } from "react";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { useEffect, useState } from "react";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BanIcon } from "lucide-react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { BanIcon, UserPlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -16,6 +17,7 @@ import {
Tooltip
} from "@app/components/v2";
import { Badge, DocumentationLinkBadge } from "@app/components/v3";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -35,6 +37,7 @@ import { OrgMembersTable } from "./OrgMembersTable";
export const OrgMembersSection = () => {
const { subscription } = useSubscription();
const { currentOrg, isSubOrganization } = useOrganization();
const navigate = useNavigate();
const orgId = currentOrg?.id ?? "";
const { user } = useUser();
const userId = user?.id || "";
@@ -55,6 +58,22 @@ export const OrgMembersSection = () => {
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const urlAction = useSearch({
from: ROUTE_PATHS.Organization.AccessControlPage.id,
select: (el) => el.action,
structuralSharing: true
});
useEffect(() => {
if (urlAction === "invite-members") {
handlePopUpOpen("addMember");
navigate({
to: ".",
search: ({ action, ...search }) => search
});
}
}, [urlAction]);
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
const { mutateAsync: deleteBatchMutateAsync } = useDeleteOrgMembershipBatch();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -184,7 +203,7 @@ export const OrgMembersSection = () => {
</div>
</div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Users</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/user-identities" />
@@ -194,13 +213,13 @@ export const OrgMembersSection = () => {
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
leftIcon={<UserPlusIcon size={16} />}
onClick={() =>
isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal()
}
isDisabled={!isAllowed}
>
Add Member
{isSubOrganization ? "Add Members" : "Invite Members"}
</Button>
)}
</OrgPermissionCan>
@@ -392,7 +392,7 @@ export const OrgMembersTable = ({
}}
/>
</Th>
<Th className="w-1/3">
<Th className="min-w-40 md:w-1/3 md:min-w-0">
<div className="flex items-center">
Name
<IconButton
@@ -505,7 +505,7 @@ export const OrgMembersTable = ({
<Td
className={twMerge("group max-w-0", isActive ? "" : "text-mineshaft-400")}
>
<div className="flex items-center gap-x-2">
<div className="flex w-full items-center gap-x-2">
<p className="truncate">
{name ?? <span className="text-mineshaft-400">Not Set</span>}
</p>
@@ -189,7 +189,7 @@ export const OrgRoleTable = () => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">
{isSubOrganization ? "Sub-" : ""}Organization Roles
@@ -209,7 +209,7 @@ export const AppConnectionsTable = ({ projectId, projectType }: Props) => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">App Connections</p>
@@ -150,8 +150,8 @@ const Page = () => {
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
@@ -116,8 +116,8 @@ const Page = () => {
)}
</div>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<IdentityDetailsSection
isOrgIdentity={data.identity.orgId === currentOrg.id}
identityId={identityId}
@@ -301,7 +301,7 @@ export const GatewayCliDeploymentMethod = () => {
<>
<FormLabel
label="Identity Token"
tooltipText="The identity token that your relay will use for authentication."
tooltipText="The identity token that your gateway will use for authentication."
className="mt-4"
/>
<Input
@@ -0,0 +1,389 @@
import { useMemo, useState } from "react";
import { SingleValue } from "react-select";
import { faCopy, faQuestionCircle, 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,
Checkbox,
FilterableSelect,
FormLabel,
IconButton,
Input,
ModalClose,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionIdentityActions,
OrgPermissionSubjects,
useOrganization,
useOrgPermission
} from "@app/context";
import {
useAddIdentityTokenAuth,
useCreateTokenIdentityTokenAuth,
useGetIdentityMembershipOrgs,
useGetIdentityTokenAuth,
useGetRelays
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
import { RelayOption } from "./RelayOption";
const baseFormSchema = z.object({
name: slugSchema({ field: "name" }),
relay: z
.object(
{
id: z.string(),
name: z.string()
},
{ required_error: "Relay is required" }
)
.nullable()
.refine((val) => val !== null, { message: "Relay is required" })
});
const formSchemaWithIdentity = baseFormSchema.extend({
identity: z
.object(
{
id: z.string(),
name: z.string()
},
{ required_error: "Identity is required" }
)
.nullable()
.refine((val) => val !== null, { message: "Identity is required" })
});
const formSchemaWithToken = baseFormSchema.extend({
identityToken: z.string().min(1, "Token is required")
});
export const GatewayCliSystemdDeploymentMethod = () => {
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 [autogenerateToken, setAutogenerateToken] = useState(true);
const [step, setStep] = useState<"form" | "command">("form");
const [name, setName] = useState("");
const [relay, setRelay] = useState<null | {
id: string;
name: string;
}>({ id: "_auto", name: "Auto Select Relay" });
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 { permission } = useOrgPermission();
const canCreateToken = permission.can(
OrgPermissionIdentityActions.CreateToken,
OrgPermissionSubjects.Identity
);
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([]);
if (canCreateToken && autogenerateToken) {
const validation = formSchemaWithIdentity.safeParse({
name,
relay,
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: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.",
type: "warning"
});
}
const token = await createToken({
identityId: validatedIdentity.id,
name: `gateway token for ${name} (autogenerated)`
});
setIdentityToken(token.accessToken);
createNotification({
text: "Automatically generated a token for the selected identity.",
type: "info"
});
setStep("command");
} catch {
setIdentityToken("");
}
} else {
const validation = formSchemaWithToken.safeParse({
name,
relay,
identityToken
});
if (!validation.success) {
setFormErrors(validation.error.issues);
return;
}
setStep("command");
}
};
const installCommand = useMemo(() => {
const relayPart = relay?.id !== "_auto" ? ` --relay=${relay?.name || ""}` : "";
return `sudo infisical gateway systemd install --name=${name}${relayPart} --domain=${siteURL} --token=${identityToken}`;
}, [name, relay, identityToken, siteURL]);
const startServiceCommand = "sudo systemctl start infisical-gateway";
if (step === "command") {
return (
<>
<FormLabel label="Installation Command" />
<div className="flex gap-2">
<Input value={installCommand} isDisabled />
<IconButton
ariaLabel="copy install command"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(installCommand);
createNotification({
text: "Installation command copied to clipboard",
type: "info"
});
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<FormLabel label="Start the Gateway Service" className="mt-4" />
<div className="mb-2 flex gap-2">
<Input value={startServiceCommand} isDisabled />
<IconButton
ariaLabel="copy start service command"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(startServiceCommand);
createNotification({
text: "Start service 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">{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: "_auto",
name: "Auto Select Relay"
},
{
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">{errors.relay}</p>}
{canCreateToken && autogenerateToken ? (
<>
<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">{errors.identity}</p>}
</>
) : (
<>
<FormLabel
label="Identity Token"
tooltipText="The identity token that your gateway will use for authentication."
className="mt-4"
/>
<Input
value={identityToken}
onChange={(e) => setIdentityToken(e.target.value)}
placeholder="Enter identity token..."
isError={Boolean(errors.identityToken)}
/>
{errors.identityToken && <p className="mt-1 text-sm text-red">{errors.identityToken}</p>}
</>
)}
{canCreateToken && (
<div className="mt-2">
<Checkbox
isChecked={autogenerateToken}
onCheckedChange={(e) => {
setAutogenerateToken(Boolean(e));
}}
id="autogenerate-token"
className="mr-2"
>
<div className="flex items-center">
<span>Automatically enable token auth and generate a token for identity</span>
<Tooltip
className="max-w-md"
content={
<>
Token authentication will be automatically enabled for the selected identity if
it isn&apos;t already configured. By default, it will be configured to allow all
IP addresses with a token TTL of 30 days. You can manage these settings in
Access Control.
<br />
<br />A token will automatically be generated to be used with the CLI command.
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="mt-0.5 ml-1" />
</Tooltip>
</div>
</Checkbox>
</div>
)}
<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>
</>
);
};
@@ -4,6 +4,7 @@ import { Modal, ModalContent } from "@app/components/v2";
import { GatewayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect";
import { GatewayCliDeploymentMethod } from "./GatewayCliDeploymentMethod";
import { GatewayCliSystemdDeploymentMethod } from "./GatewayCliSystemdDeploymentMethod";
type Props = {
isOpen: boolean;
@@ -11,7 +12,8 @@ type Props = {
};
export const GatewayDeploymentInfoMap = {
cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod }
cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod },
systemd: { name: "CLI (systemd)", image: "SSH.png", component: GatewayCliSystemdDeploymentMethod }
} as const;
export type GatewayDeploymentMethod = keyof typeof GatewayDeploymentInfoMap;
@@ -156,15 +156,6 @@ export const RelayCliDeploymentMethod = () => {
}
};
const handleIdentityChange = (
selectedIdentity: SingleValue<{
id: string;
name: string;
}>
) => {
setIdentity(selectedIdentity);
};
const command = useMemo(() => {
return `infisical relay start --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`;
}, [name, siteURL, host, identityToken]);
@@ -245,7 +236,7 @@ export const RelayCliDeploymentMethod = () => {
<FilterableSelect
value={identity}
onChange={(e) =>
handleIdentityChange(
setIdentity(
e as SingleValue<{
id: string;
name: string;
@@ -0,0 +1,362 @@
import { useMemo, useState } from "react";
import { SingleValue } from "react-select";
import { faCopy, faQuestionCircle, 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,
Checkbox,
FilterableSelect,
FormLabel,
IconButton,
Input,
ModalClose,
Tooltip
} from "@app/components/v2";
import {
OrgPermissionIdentityActions,
OrgPermissionSubjects,
useOrganization,
useOrgPermission
} from "@app/context";
import {
useAddIdentityTokenAuth,
useCreateTokenIdentityTokenAuth,
useGetIdentityMembershipOrgs,
useGetIdentityTokenAuth
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
const baseFormSchema = z.object({
name: slugSchema({ field: "name" }),
host: z.string().min(1, "Host is required")
});
const formSchemaWithIdentity = baseFormSchema.extend({
identity: z
.object(
{
id: z.string(),
name: z.string()
},
{ required_error: "Identity is required" }
)
.nullable()
.refine((val) => val !== null, { message: "Identity is required" })
});
const formSchemaWithToken = baseFormSchema.extend({
identityToken: z.string().min(1, "Token is required")
});
export const RelayCliSystemdDeploymentMethod = () => {
const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`;
const [autogenerateToken, setAutogenerateToken] = useState(true);
const [step, setStep] = useState<"form" | "command">("form");
const [name, setName] = useState("");
const [host, setHost] = useState("");
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 { permission } = useOrgPermission();
const canCreateToken = permission.can(
OrgPermissionIdentityActions.CreateToken,
OrgPermissionSubjects.Identity
);
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([]);
if (canCreateToken && autogenerateToken) {
const validation = formSchemaWithIdentity.safeParse({ name, host, 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: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.",
type: "warning"
});
}
const token = await createToken({
identityId: validatedIdentity.id,
name: `relay token for ${name} (autogenerated)`
});
setIdentityToken(token.accessToken);
createNotification({
text: "Automatically generated a token for the selected identity.",
type: "info"
});
setStep("command");
} catch {
setIdentityToken("");
}
} else {
const validation = formSchemaWithToken.safeParse({
name,
host,
identityToken
});
if (!validation.success) {
setFormErrors(validation.error.issues);
return;
}
setStep("command");
}
};
const installCommand = useMemo(() => {
return `sudo infisical relay systemd install --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`;
}, [name, siteURL, host, identityToken]);
const startServiceCommand = "sudo systemctl start infisical-relay";
const enableServiceCommand = "sudo systemctl enable infisical-relay";
if (step === "command") {
return (
<>
<FormLabel label="Installation Command" />
<div className="flex gap-2">
<Input value={installCommand} isDisabled />
<IconButton
ariaLabel="copy install command"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(installCommand);
createNotification({
text: "Installation command copied to clipboard",
type: "info"
});
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<FormLabel label="Start the Relay Service" className="mt-4" />
<div className="mb-2 flex gap-2">
<Input value={startServiceCommand} isDisabled />
<IconButton
ariaLabel="copy start service command"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(startServiceCommand);
createNotification({
text: "Start service command copied to clipboard",
type: "info"
});
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<div className="flex gap-2">
<Input value={enableServiceCommand} isDisabled />
<IconButton
ariaLabel="copy enable service command"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(enableServiceCommand);
createNotification({
text: "Enable service 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">{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">{errors.host}</p>}
{canCreateToken && autogenerateToken ? (
<>
<FormLabel
label="Identity"
tooltipText="The identity that your relay 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">{errors.identity}</p>}
</>
) : (
<>
<FormLabel
label="Identity Token"
tooltipText="The identity token that your relay will use for authentication."
className="mt-4"
/>
<Input
value={identityToken}
onChange={(e) => setIdentityToken(e.target.value)}
placeholder="Enter identity token..."
isError={Boolean(errors.identityToken)}
/>
{errors.identityToken && <p className="mt-1 text-sm text-red">{errors.identityToken}</p>}
</>
)}
{canCreateToken && (
<div className="mt-2">
<Checkbox
isChecked={autogenerateToken}
onCheckedChange={(e) => {
setAutogenerateToken(Boolean(e));
}}
id="autogenerate-token"
className="mr-2"
>
<div className="flex items-center">
<span>Automatically enable token auth and generate a token for identity</span>
<Tooltip
className="max-w-md"
content={
<>
Token authentication will be automatically enabled for the selected identity if
it isn&apos;t already configured. By default, it will be configured to allow all
IP addresses with a token TTL of 30 days. You can manage these settings in
Access Control.
<br />
<br />A token will automatically be generated to be used with the CLI command.
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="mt-0.5 ml-1" />
</Tooltip>
</div>
</Checkbox>
</div>
)}
<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>
</>
);
};
@@ -4,6 +4,7 @@ import { Modal, ModalContent } from "@app/components/v2";
import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect";
import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod";
import { RelayCliSystemdDeploymentMethod } from "./RelayCliSystemdDeploymentMethod";
import { RelayTerraformDeploymentMethod } from "./RelayTerraformDeploymentMethod";
type Props = {
@@ -13,6 +14,7 @@ type Props = {
export const RelayDeploymentInfoMap = {
cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod },
systemd: { name: "CLI (systemd)", image: "SSH.png", component: RelayCliSystemdDeploymentMethod },
terraform: {
name: "Terraform",
image: "Terraform.png",
@@ -190,15 +190,6 @@ export const RelayTerraformDeploymentMethod = () => {
}
};
const handleIdentityChange = (
selectedIdentity: SingleValue<{
id: string;
name: string;
}>
) => {
setIdentity(selectedIdentity);
};
const terraformCommand = useMemo(() => {
return `terraform {
required_providers {
@@ -365,7 +356,7 @@ resource "aws_eip_association" "eip_assoc" {
<FilterableSelect
value={identity}
onChange={(e) =>
handleIdentityChange(
setIdentity(
e as SingleValue<{
id: string;
name: string;
@@ -125,7 +125,7 @@ export const AllProjectView = ({
return (
<div>
<div className="flex w-full flex-row">
<div className="flex w-full flex-row flex-wrap gap-2 md:flex-nowrap md:gap-0">
<ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
@@ -360,7 +360,7 @@ export const MyProjectView = ({
return (
<div>
<div className="flex w-full flex-row">
<div className="flex w-full flex-row flex-wrap gap-2 md:flex-nowrap md:gap-0">
<ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50/60 duration-200 focus:bg-mineshaft-700/80"
@@ -37,7 +37,7 @@ export const ExternalMigrationsTab = () => {
</p>
</div>
<div className="flex items-start justify-between">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<p className="text-base font-medium text-mineshaft-100">
@@ -71,7 +71,7 @@ export const VaultConnectionSection = () => {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<img
src="/images/integrations/Vault.png"
@@ -34,9 +34,11 @@ export const ProjectTemplatesSection = () => {
project setup
</p>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/project-templates" />
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/project-templates" />
</div>
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.ProjectTemplates}
@@ -57,7 +59,7 @@ export const ProjectTemplatesSection = () => {
handlePopUpOpen("addTemplate");
}}
isDisabled={!isAllowed}
className="ml-auto"
// className="ml-auto"
>
Add Template
</Button>
@@ -242,8 +242,8 @@ const Page = withPermission(
)}
</div>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full space-y-2">
@@ -1,13 +1,9 @@
import { Button } from "@app/components/v2";
export enum AccountView {
Flat = "flat",
Nested = "nested"
}
import { PamAccountView } from "@app/hooks/api/pam";
type Props = {
value: AccountView;
onChange: (value: AccountView) => void;
value: PamAccountView;
onChange: (value: PamAccountView) => void;
};
export const AccountViewToggle = ({ value, onChange }: Props) => {
@@ -16,11 +12,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
<Button
variant="outline_bg"
onClick={() => {
onChange(AccountView.Flat);
onChange(PamAccountView.Flat);
}}
size="xs"
className={`${
value === AccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
value === PamAccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Hide Folders
@@ -28,11 +24,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
<Button
variant="outline_bg"
onClick={() => {
onChange(AccountView.Nested);
onChange(PamAccountView.Nested);
}}
size="xs"
className={`${
value === AccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
value === PamAccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Show Folders
@@ -58,15 +58,19 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
return duration;
}, [duration]);
const command = useMemo(
() =>
account &&
(account.resource.resourceType === PamResourceType.Postgres ||
account.resource.resourceType === PamResourceType.MySQL)
? `infisical pam db access-account ${account.id} --duration ${cliDuration}`
: "",
[account, cliDuration]
);
const command = useMemo(() => {
if (!account) return "";
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
case PamResourceType.MySQL:
return `infisical pam db access-account ${account.id} --duration ${cliDuration}`;
case PamResourceType.SSH:
return `infisical pam ssh access-account ${account.id} --duration ${cliDuration}`;
default:
return "";
}
}, [account, cliDuration]);
if (!account) return null;
@@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader";
import { MySQLAccountForm } from "./MySQLAccountForm";
import { PostgresAccountForm } from "./PostgresAccountForm";
import { SshAccountForm } from "./SshAccountForm";
type FormProps = {
onComplete: (account: TPamAccount) => void;
@@ -65,6 +66,10 @@ const CreateForm = ({
return (
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
case PamResourceType.SSH:
return (
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -90,9 +95,11 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
return <PostgresAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}
@@ -0,0 +1,272 @@
import { useEffect, useState } from "react";
import { Controller, FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { PamResourceType, TSSHAccount } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
type Props = {
account?: TSSHAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>;
};
const SSHPasswordCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string().trim().min(1, "Username is required"),
password: z.string().trim().min(1, "Password is required")
});
const SSHPublicKeyCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string().trim().min(1, "Username is required"),
privateKey: z.string().trim().min(1, "Private key is required")
});
const SSHCertificateCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string().trim().min(1, "Username is required")
});
const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [
SSHPasswordCredentialsSchema,
SSHPublicKeyCredentialsSchema,
SSHCertificateCredentialsSchema
]);
const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSshAccountSchema,
// We don't support rotation for now, just feed a false value to
// make the schema happy
rotationEnabled: z.boolean().default(false)
});
type FormData = z.infer<typeof formSchema>;
const SshAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control, setValue } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const authMethod =
useWatch({ control, name: "credentials.authMethod" }) || SSHAuthMethod.Password;
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<Controller
name="credentials.authMethod"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="mb-3"
isError={Boolean(error?.message)}
errorText={error?.message}
label="Authentication Method"
>
<Select
value={value || SSHAuthMethod.Password}
onValueChange={(newAuthMethod) => {
onChange(newAuthMethod);
// Clear out credentials from other auth methods
setValue("credentials.password", undefined, { shouldDirty: true });
setValue("credentials.privateKey", undefined, { shouldDirty: true });
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value={SSHAuthMethod.Password}>Password</SelectItem>
<SelectItem value={SSHAuthMethod.PublicKey}>SSH Key</SelectItem>
<SelectItem value={SSHAuthMethod.Certificate}>Certificate</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-3"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
{authMethod === SSHAuthMethod.Password && (
<Controller
name="credentials.password"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
>
<Input
{...field}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
setShowPassword(true);
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
setShowPassword(false);
}}
/>
</FormControl>
)}
/>
)}
{authMethod === SSHAuthMethod.PublicKey && (
<Controller
name="credentials.privateKey"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Private Key"
>
<TextArea
{...field}
value={field.value === UNCHANGED_PASSWORD_SENTINEL ? "" : field.value}
className="min-h-32 resize-y font-mono text-xs"
placeholder={
isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL
? "Private key unchanged - click to update"
: "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
}
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
}}
/>
</FormControl>
)}
/>
)}
{authMethod === SSHAuthMethod.Certificate && (
<p className="mb-0 text-xs text-mineshaft-400">
Certificate-based authentication will use the certificate configured on the SSH resource.
</p>
)}
</div>
);
};
export const SshAccountForm = ({ account, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const getDefaultCredentials = () => {
if (!account) return undefined;
if (account.credentials.authMethod === SSHAuthMethod.Password) {
return {
...account.credentials,
password: UNCHANGED_PASSWORD_SENTINEL
};
}
if (account.credentials.authMethod === SSHAuthMethod.PublicKey) {
return {
...account.credentials,
privateKey: UNCHANGED_PASSWORD_SENTINEL
};
}
return account.credentials;
};
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: account
? {
...account,
credentials: getDefaultCredentials()
}
: {
name: "",
description: "",
credentials: {
authMethod: SSHAuthMethod.Password,
username: "",
password: ""
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<GenericAccountFields />
<SshAccountFields 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 Account" : "Create Account"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -1,23 +1,9 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamAccounts } from "@app/hooks/api/pam";
import { PamAccountsTable } from "./PamAccountsTable";
export const PamAccountsSection = () => {
const { currentProject } = useProject();
const { data, isPending } = useListPamAccounts(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return (
<PamAccountsTable
projectId={currentProject.id}
accounts={data?.accounts || []}
folders={data?.folders || []}
/>
);
return <PamAccountsTable projectId={currentProject.id} />;
};
@@ -29,6 +29,7 @@ import {
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
@@ -40,11 +41,22 @@ import {
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount, TPamFolder } from "@app/hooks/api/pam";
import {
PAM_RESOURCE_TYPE_MAP,
PamAccountOrderBy,
PamAccountView,
TPamFolder
} from "@app/hooks/api/pam";
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
import { AccountView, AccountViewToggle } from "./AccountViewToggle";
import { AccountViewToggle } from "./AccountViewToggle";
import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
import { PamAccessAccountModal } from "./PamAccessAccountModal";
import { PamAccountRow } from "./PamAccountRow";
@@ -56,21 +68,15 @@ import { PamFolderRow } from "./PamFolderRow";
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
enum OrderBy {
Name = "name"
}
type Filters = {
resource: string[];
type PamAccountFilter = {
resourceIds: string[];
};
type Props = {
accounts: TPamAccount[];
folders: TPamFolder[];
projectId: string;
};
export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
export const PamAccountsTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -92,18 +98,21 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
from: ROUTE_PATHS.Pam.AccountsPage.id
});
const [accountView, setAccountView] = useState<AccountView>(initAccountView ?? AccountView.Flat);
const [accountView, setAccountView] = useState<PamAccountView>(
initAccountView ?? PamAccountView.Flat
);
const [filters, setFilters] = useState<Filters>({
resource: []
const [filter, setFilter] = useState<PamAccountFilter>({
resourceIds: []
});
const {
search,
debouncedSearch,
setSearch,
setPage,
page,
perPage,
setPage,
setPerPage,
offset,
orderDirection,
@@ -111,114 +120,74 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
} = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, {
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 20),
initSearch
});
const { foldersByParentId, pathMap, folderPaths } = useMemo(() => {
const foldersById: Record<string, TPamFolder> = {};
const tempFoldersByParentId: Record<string, TPamFolder[]> = { null: [] };
const tempPathMap: Record<string, string> = { "/": "null" };
const tempFolderPaths: Record<string, string> = {};
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, newPerPage);
};
folders.forEach((folder) => {
foldersById[folder.id] = folder;
if (!tempFoldersByParentId[folder.parentId || "null"]) {
tempFoldersByParentId[folder.parentId || "null"] = [];
}
tempFoldersByParentId[folder.parentId || "null"].push(folder);
});
const { data, isLoading } = useListPamAccounts({
projectId,
accountPath,
accountView,
offset,
limit: perPage,
search: debouncedSearch,
orderBy,
orderDirection,
filterResourceIds: filter.resourceIds.length ? filter.resourceIds.join(",") : undefined
});
const buildPaths = (parentId: string | null, currentPath: string) => {
(tempFoldersByParentId[parentId || "null"] || []).forEach((folder) => {
const newPath = `${currentPath}${folder.name}/`;
tempPathMap[newPath] = folder.id;
tempFolderPaths[folder.id] = newPath;
buildPaths(folder.id, newPath);
});
};
buildPaths(null, "/");
return {
foldersByParentId: tempFoldersByParentId,
pathMap: tempPathMap,
folderPaths: tempFolderPaths
};
}, [folders]);
const effectiveFolderIdForFiltering = useMemo(() => {
if (accountView === AccountView.Flat) {
return null;
}
const folderId = pathMap[accountPath];
return folderId === "null" ? null : folderId || null;
}, [accountView, accountPath, pathMap]);
const foldersToRender = useMemo(() => {
if (accountView === AccountView.Flat) {
return [];
}
return (foldersByParentId[effectiveFolderIdForFiltering || "null"] || []).filter((folder) =>
folder.name.toLowerCase().includes(search.trim().toLowerCase())
);
}, [accountView, effectiveFolderIdForFiltering, foldersByParentId, search]);
const accountsToProcess = useMemo(() => {
if (accountView === AccountView.Flat) {
return accounts;
}
return accounts.filter(
(acc) => (acc.folderId || "null") === (effectiveFolderIdForFiltering || "null")
);
}, [accountView, accounts, effectiveFolderIdForFiltering]);
const filteredAccounts = useMemo(
() =>
accountsToProcess
.filter((account) => {
const {
name,
description,
resource: { name: resourceName, id: resourceId }
} = account;
if (filters.resource.length && !filters.resource.includes(resourceId)) {
return false;
}
const searchValue = search.trim().toLowerCase();
const path = (account.folderId && folderPaths[account.folderId]) || "";
return (
name.toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue) ||
(description || "").toLowerCase().includes(searchValue) ||
path.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [accOne, accTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return accOne.name.toLowerCase().localeCompare(accTwo.name.toLowerCase());
}
}),
[accountsToProcess, orderDirection, search, orderBy, filters, folderPaths]
);
const accounts = data?.accounts || [];
const folders = data?.folders || [];
const totalCount = data?.totalCount || 0;
const folderPaths = data?.folderPaths || {};
const currentFolderId = data?.folderId ?? null;
useResetPageHelper({
totalCount: filteredAccounts.length,
totalCount,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredAccounts.slice(offset, perPage * page),
[filteredAccounts, offset, perPage, page]
const foldersToRender = useMemo(() => {
if (accountView === PamAccountView.Flat) {
return [];
}
return folders.filter((folder) =>
folder.name.toLowerCase().includes(search.trim().toLowerCase())
);
}, [accountView, folders, search]);
const filteredAccounts = useMemo(
() =>
accounts.filter((account) => {
const {
name,
description,
resource: { name: resourceName, id: resourceId }
} = account;
if (filter.resourceIds.length && !filter.resourceIds.includes(resourceId)) {
return false;
}
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue) ||
(description || "").toLowerCase().includes(searchValue)
);
}),
[accounts, search, filter]
);
const handleSort = (column: OrderBy) => {
const handleSort = (column: PamAccountOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
@@ -228,15 +197,16 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getClassName = (col: PamAccountOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
const getColSortIcon = (col: PamAccountOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resource.length);
const isTableFiltered = Boolean(filter.resourceIds.length);
const handleFolderClick = (folder: TPamFolder) => {
if (accountView === AccountView.Flat) {
if (accountView === PamAccountView.Flat) {
return;
}
const newPath = `${accountPath}${folder.name}/`;
@@ -246,17 +216,17 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
const uniqueResources = useMemo(() => {
const resourceMap = new Map<string, TPamAccount["resource"]>();
accounts.forEach((account) => {
resourceMap.set(account.resource.id, account.resource);
});
return Array.from(resourceMap.values());
}, [accounts]);
const { data: resourcesData } = useListPamResources({
projectId,
// temporarily returning a large number until we rework table filtering
limit: 100
});
const resources = resourcesData?.resources || [];
return (
<div>
{accountView === AccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
<div className="mt-4 flex gap-2">
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
{(isAllowed) =>
@@ -264,12 +234,14 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
<AccountViewToggle
value={accountView}
onChange={(e) => {
setPage(1);
setFilter({ resourceIds: [] });
setAccountView(e);
navigate({
search: (prev) => ({
...prev,
accountView: e === AccountView.Flat ? undefined : e,
accountPath: e === AccountView.Flat ? "/" : prev.accountPath
accountView: e === PamAccountView.Flat ? undefined : e,
accountPath: e === PamAccountView.Flat ? "/" : prev.accountPath
})
});
}}
@@ -307,25 +279,25 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
<DropdownMenuLabel>Resource</DropdownMenuLabel>
{uniqueResources.length ? (
uniqueResources.map((resource) => {
{resources.length ? (
resources.map((resource) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
const newResources = filters.resource.includes(resource.id)
? filters.resource.filter((a) => a !== resource.id)
: [...filters.resource, resource.id];
setFilters((prev) => ({
const newResources = filter.resourceIds.includes(resource.id)
? filter.resourceIds.filter((a) => a !== resource.id)
: [...filter.resourceIds, resource.id];
setFilter((prev) => ({
...prev,
resource: newResources
resourceIds: newResources
}));
}}
key={resource.id}
icon={
filters.resource.includes(resource.id) && (
filter.resourceIds.includes(resource.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
@@ -358,12 +330,12 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addAccount")}
isDisabled={!isAllowedToCreateAccounts}
className={`h-10 transition-colors ${accountView === AccountView.Flat ? "" : "rounded-r-none"}`}
className={`h-10 transition-colors ${accountView === PamAccountView.Flat ? "" : "rounded-r-none"}`}
>
Add Account
</Button>
{accountView !== AccountView.Flat && (
{accountView !== PamAccountView.Flat && (
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
@@ -414,11 +386,11 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
Accounts
<IconButton
variant="plain"
className={getClassName(OrderBy.Name)}
className={getClassName(PamAccountOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)}
onClick={() => handleSort(PamAccountOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
</IconButton>
</div>
</Th>
@@ -426,45 +398,48 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
</Tr>
</THead>
<TBody>
{accountView !== AccountView.Flat &&
foldersToRender.map((folder) => (
<PamFolderRow
key={folder.id}
folder={folder}
search={search}
onClick={() => handleFolderClick(folder)}
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
/>
))}
{currentPageData.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
isFlatView={accountView === AccountView.Flat}
accountPath={
account.folderId ? folderPaths[account.folderId]?.slice(0, -1) : undefined
}
onAccess={(e) => {
handlePopUpOpen("accessAccount", e);
}}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
{!isLoading && (
<>
{accountView !== PamAccountView.Flat &&
foldersToRender.map((folder) => (
<PamFolderRow
key={folder.id}
folder={folder}
search={search}
onClick={() => handleFolderClick(folder)}
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
/>
))}
{filteredAccounts.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
isFlatView={accountView === PamAccountView.Flat}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e) => {
handlePopUpOpen("accessAccount", e);
}}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</>
)}
</TBody>
</Table>
{Boolean(filteredAccounts.length) && (
{Boolean(totalCount) && !isLoading && (
<Pagination
count={filteredAccounts.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{isContentEmpty && (
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
@@ -485,7 +460,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
projectId={projectId}
currentFolderId={effectiveFolderIdForFiltering}
currentFolderId={currentFolderId}
/>
<PamAccessAccountModal
isOpen={popUp.accessAccount.isOpen}
@@ -506,7 +481,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
isOpen={popUp.addAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
projectId={projectId}
currentFolderId={effectiveFolderIdForFiltering}
currentFolderId={currentFolderId}
/>
</div>
);
@@ -14,8 +14,6 @@ type Props = {
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
const createPamFolder = useCreatePamFolder();
console.log({ currentFolderId });
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
await createPamFolder.mutateAsync({
...formData,
@@ -1,11 +1,12 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { SingleValue } from "react-select";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2";
import { Button, FilterableSelect, FormControl, ModalClose } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDebounce, usePopUp } from "@app/hooks";
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
@@ -28,7 +29,17 @@ type FormData = z.infer<typeof formSchema>;
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
const { permission } = useProjectPermission();
const { isPending, data: resources } = useListPamResources(projectId);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 350);
const { isPending, data } = useListPamResources({
projectId,
limit: 100,
search: debouncedSearch
});
const resources = data?.resources || [];
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
@@ -43,15 +54,6 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
ProjectPermissionSub.PamResources
);
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<>
<FormProvider {...form}>
@@ -65,6 +67,12 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
<FilterableSelect
value={value}
inputValue={search}
onInputChange={(val, actionMeta) => {
if (actionMeta.action === "input-change") {
setSearch(val);
}
}}
onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
handlePopUpOpen("addResource");
@@ -2,12 +2,13 @@ import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { AccountView } from "./components/AccountViewToggle";
import { PamAccountView } from "@app/hooks/api/pam";
import { PamAccountsPage } from "./PamAccountsPage";
const PamAccountsPageQueryParamsSchema = z.object({
search: z.string().optional(),
accountView: z.nativeEnum(AccountView).optional(),
accountView: z.nativeEnum(PamAccountView).optional(),
accountPath: z.string().catch("/")
});
@@ -11,6 +11,7 @@ import { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader";
import { MySQLResourceForm } from "./MySQLResourceForm";
import { PostgresResourceForm } from "./PostgresResourceForm";
import { SSHResourceForm } from "./SSHResourceForm";
type FormProps = {
onComplete: (resource: TPamResource) => void;
@@ -51,6 +52,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
return <PostgresResourceForm onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -79,6 +82,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
}
@@ -0,0 +1,72 @@
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 { PamResourceType, TSSHResource } from "@app/hooks/api/pam";
import { SshResourceFields } from "./shared/SshResourceFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = {
resource?: TSSHResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const BaseSshConnectionDetailsSchema = z.object({
host: z.string().trim().min(1, "Host is required"),
port: z.number().int().min(1).max(65535)
});
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.SSH),
connectionDetails: BaseSshConnectionDetailsSchema
});
type FormData = z.infer<typeof formSchema>;
export const SSHResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.SSH,
connectionDetails: {
host: "",
port: 22
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericResourceFields />
<SshResourceFields />
<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>
);
};
@@ -0,0 +1,42 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2";
export const SshResourceFields = () => {
const { control } = useFormContext();
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="connectionDetails.host"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input placeholder="example.com or 192.168.1.1" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} />
</FormControl>
)}
/>
</div>
</div>
);
};
@@ -1,17 +1,9 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamResources } from "@app/hooks/api/pam";
import { PamResourcesTable } from "./PamResourcesTable";
export const PamResourcesSection = () => {
const { currentProject } = useProject();
const { data: resources = [], isPending } = useListPamResources(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return <PamResourcesTable resources={resources} projectId={currentProject.id} />;
return <PamResourcesTable projectId={currentProject.id} />;
};
@@ -27,6 +27,7 @@ import {
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
@@ -39,29 +40,34 @@ import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, PamResourceType, TPamResource } from "@app/hooks/api/pam";
import {
PAM_RESOURCE_TYPE_MAP,
PamResourceOrderBy,
PamResourceType,
useListPamResources
} from "@app/hooks/api/pam";
import { PamAddResourceModal } from "./PamAddResourceModal";
import { PamDeleteResourceModal } from "./PamDeleteResourceModal";
import { PamResourceRow } from "./PamResourceRow";
import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
enum OrderBy {
Name = "name"
}
type Filters = {
resourceType: PamResourceType[];
type PamResourceFilter = {
resourceTypes: PamResourceType[];
};
type Props = {
projectId: string;
resources: TPamResource[];
};
export const PamResourcesTable = ({ projectId, resources }: Props) => {
export const PamResourcesTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path });
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
@@ -74,12 +80,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
from: ROUTE_PATHS.Pam.ResourcesPage.id
});
const [filters, setFilters] = useState<Filters>({
resourceType: []
const [filter, setFilter] = useState<PamResourceFilter>({
resourceTypes: []
});
const {
search,
debouncedSearch,
setSearch,
setPage,
page,
@@ -91,51 +98,55 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
} = usePagination<PamResourceOrderBy>(PamResourceOrderBy.Name, {
initPerPage: getUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, 20),
initSearch
});
const filteredResources = useMemo(
() =>
resources
.filter((resource) => {
const { name, resourceType } = resource;
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, newPerPage);
};
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) {
return false;
}
const { data, isLoading } = useListPamResources({
projectId,
offset,
limit: perPage,
search: debouncedSearch,
orderBy,
orderDirection,
filterResourceTypes: filter.resourceTypes.length ? filter.resourceTypes.join(",") : undefined
});
const searchValue = search.trim().toLowerCase();
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
name.toLowerCase().includes(searchValue) ||
resourceTypeName.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return one.name.toLowerCase().localeCompare(two.name.toLowerCase());
}
}),
[resources, orderDirection, search, orderBy, filters]
);
const resources = data?.resources || [];
const totalCount = data?.totalCount || 0;
useResetPageHelper({
totalCount: filteredResources.length,
totalCount,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredResources.slice(offset, perPage * page),
[filteredResources, offset, perPage, page]
const filteredResources = useMemo(
() =>
resources.filter((resource) => {
const { name, resourceType } = resource;
if (filter.resourceTypes.length && !filter.resourceTypes.includes(resourceType)) {
return false;
}
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
resourceType.toLowerCase().includes(searchValue)
);
}),
[resources, search, filter]
);
const handleSort = (column: OrderBy) => {
const handleSort = (column: PamResourceOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
@@ -145,12 +156,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getClassName = (col: PamResourceOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
const getColSortIcon = (col: PamResourceOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceType.length);
const isTableFiltered = Boolean(filter.resourceTypes.length);
const isContentEmpty = !filteredResources.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
@@ -187,43 +199,38 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
{resources.length ? (
[...new Set(resources.map(({ resourceType }) => resourceType))].map((type) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
resourceType: prev.resourceType.includes(type)
? prev.resourceType.filter((a) => a !== type)
: [...prev.resourceType, type]
}));
}}
key={type}
icon={
filters.resourceType.includes(type) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} resource type`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Resources</DropdownMenuItem>
)}
{Object.entries(PAM_RESOURCE_TYPE_MAP).map(([type, { name, image }]) => {
const resourceType = type as PamResourceType;
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilter((prev) => ({
...prev,
resourceTypes: prev.resourceTypes.includes(resourceType)
? prev.resourceTypes.filter((a) => a !== resourceType)
: [...prev.resourceTypes, resourceType]
}));
}}
key={resourceType}
icon={
filter.resourceTypes.includes(resourceType) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} resource type`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<OrgPermissionCan
@@ -263,11 +270,11 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
Resource
<IconButton
variant="plain"
className={getClassName(OrderBy.Name)}
className={getClassName(PamResourceOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)}
onClick={() => handleSort(PamResourceOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
<FontAwesomeIcon icon={getColSortIcon(PamResourceOrderBy.Name)} />
</IconButton>
</div>
</Th>
@@ -275,27 +282,29 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
</Tr>
</THead>
<TBody>
{currentPageData.map((resource) => (
<PamResourceRow
key={resource.id}
resource={resource}
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
search={search.trim().toLowerCase()}
/>
))}
{isLoading && <TableSkeleton columns={2} innerKey="pam-resources" />}
{!isLoading &&
filteredResources.map((resource) => (
<PamResourceRow
key={resource.id}
resource={resource}
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
search={search.trim().toLowerCase()}
/>
))}
</TBody>
</Table>
{Boolean(filteredResources.length) && (
{Boolean(totalCount) && !isLoading && (
<Pagination
count={filteredResources.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{isContentEmpty && (
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No resources match search" : "No resources"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
@@ -78,7 +78,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.SSH ||
resource === PamResourceType.Kubernetes ||
resource === PamResourceType.MCP ||
resource === PamResourceType.Redis ||
@@ -0,0 +1,129 @@
import { useMemo, useState } from "react";
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Input } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { TPamCommandLog } from "@app/hooks/api/pam";
import { formatLogContent } from "./PamSessionLogsSection.utils";
type Props = {
logs: TPamCommandLog[];
};
export const CommandLogView = ({ logs }: Props) => {
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const toggleExpand = (timestamp: string) => {
setExpandedLogTimestamps((prev) => {
if (prev.has(timestamp)) {
return new Set();
}
return new Set([timestamp]);
});
};
const filteredLogs = useMemo(
() =>
logs.filter((log) => {
const searchValue = search.trim().toLowerCase();
return (
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
);
}),
[logs, search]
);
return (
<>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search logs..."
className="flex-1 bg-mineshaft-800"
containerClassName="bg-transparent"
/>
</div>
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
{filteredLogs.length > 0 ? (
filteredLogs.map((log, index) => {
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
const formattedInput = formatLogContent(log.input);
const logKey = `${log.timestamp}-${index}`;
return (
<button
type="button"
key={logKey}
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
}`}
onClick={() => toggleExpand(log.timestamp)}
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center gap-2 select-none">
<FontAwesomeIcon
icon={faChevronRight}
className={twMerge(
"size-3 transition-transform duration-100 ease-in-out",
isExpanded && "rotate-90"
)}
/>
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
</div>
<div
className={`mt-2 font-mono ${
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
}`}
>
<HighlightText text={formattedInput} highlight={search} />
</div>
<div
className={twMerge(
"grid transition-all duration-100 ease-in-out",
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
{log.output && (
<div className="pt-2 text-bunker-300">
<HighlightText text={log.output} highlight={search} />
</div>
)}
</div>
</div>
</button>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
{search.length ? (
<div className="text-center">
<div className="mb-2">No logs match search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
</div>
)}
</div>
</>
);
};
@@ -1,44 +1,19 @@
import { useMemo, useState } from "react";
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
import { Input } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { TPamSession } from "@app/hooks/api/pam";
import { formatLogContent } from "./PamSessionLogsSection.utils";
import { CommandLogView } from "./CommandLogView";
import { TerminalEventView } from "./TerminalEventView";
type Props = {
session: TPamSession;
};
export const PamSessionLogsSection = ({ session }: Props) => {
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const toggleExpand = (timestamp: string) => {
setExpandedLogTimestamps((prev) => {
if (prev.has(timestamp)) {
return new Set();
}
return new Set([timestamp]);
});
};
const filteredLogs = useMemo(
() =>
session.commandLogs.filter((log) => {
const { input, output } = log;
const searchValue = search.trim().toLowerCase();
return (
input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue)
);
}),
[session.commandLogs, search]
);
// Determine log type based on resource type
const isSSHSession = session.resourceType === PamResourceType.SSH;
const isDatabaseSession =
session.resourceType === PamResourceType.Postgres ||
session.resourceType === PamResourceType.MySQL;
const hasLogs = session.logs.length > 0;
return (
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -46,91 +21,20 @@ export const PamSessionLogsSection = ({ session }: Props) => {
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
</div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => {
const newSearch = e.target.value;
setSearch(newSearch);
}}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search logs..."
className="flex-1 bg-mineshaft-800"
containerClassName="bg-transparent"
/>
</div>
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
{filteredLogs.length > 0 ? (
filteredLogs.map((log) => {
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
const formattedInput = formatLogContent(log.input);
return (
<button
type="button"
key={log.timestamp}
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
}`}
onClick={() => toggleExpand(log.timestamp)}
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center gap-2 select-none">
<FontAwesomeIcon
icon={faChevronRight}
className={twMerge(
"size-3 transition-transform duration-100 ease-in-out",
isExpanded && "rotate-90"
)}
/>
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
</div>
<div
className={`mt-2 font-mono ${
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
}`}
>
<HighlightText text={formattedInput} highlight={search} />
</div>
<div
className={twMerge(
"grid transition-all duration-100 ease-in-out",
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
{log.output && (
<div className="pt-2 text-bunker-300">
<HighlightText text={log.output} highlight={search} />
</div>
)}
</div>
</div>
</button>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
{search.length ? (
<div className="text-center">
<div className="mb-2">No logs match search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
{!hasLogs && (
<div className="flex grow items-center justify-center text-bunker-300">
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,86 @@
import { useMemo, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Input } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { TTerminalEvent } from "@app/hooks/api/pam";
import { aggregateTerminalEvents } from "./terminal-utils";
type Props = {
events: TTerminalEvent[];
};
export const TerminalEventView = ({ events }: Props) => {
const [search, setSearch] = useState("");
const aggregatedEvents = useMemo(() => aggregateTerminalEvents(events), [events]);
const filteredEvents = useMemo(
() =>
aggregatedEvents.filter((event) => {
const searchValue = search.trim().toLowerCase();
if (!searchValue) return true;
return event.data.toLowerCase().includes(searchValue);
}),
[aggregatedEvents, search]
);
return (
<>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search terminal output..."
className="flex-1 bg-mineshaft-800"
containerClassName="bg-transparent"
/>
</div>
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
{filteredEvents.length > 0 ? (
filteredEvents.map((event, index) => {
const eventKey = `${event.timestamp}-${index}`;
return (
<div
key={eventKey}
className="flex w-full flex-col rounded-md border border-mineshaft-700 bg-mineshaft-800 p-3"
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center gap-2 text-xs">
<span>{new Date(event.timestamp).toLocaleString()}</span>
</div>
</div>
<div className="mt-2 font-mono whitespace-pre-wrap text-bunker-100">
<HighlightText text={event.data} highlight={search} />
</div>
</div>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
{search.length ? (
<div className="text-center">
<div className="mb-2">No terminal output matches search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Terminal session logs are not yet available</div>
<div className="text-xs text-bunker-400">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
</div>
</div>
)}
</div>
)}
</div>
</>
);
};
@@ -0,0 +1,72 @@
import { TTerminalEvent } from "@app/hooks/api/pam";
// Strip ANSI escape codes from terminal output
export const stripAnsiCodes = (text: string): string => {
// Remove ANSI escape sequences
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, "");
};
export type AggregatedTerminalEvent = {
timestamp: string;
eventType: string;
data: string;
elapsedTime: number;
eventCount: number;
};
// Aggregate consecutive output events to avoid character-by-character display
export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => {
// Filter to only show output events (input is echoed, so redundant)
const outputEvents = events.filter((e) => e.eventType === "output");
if (outputEvents.length === 0) return [];
// First, combine all events into one string to process
const allText = outputEvents
.map((e) => {
try {
return stripAnsiCodes(atob(e.data));
} catch {
return "";
}
})
.join("");
// Split on lines that contain shell prompts
// Pattern matches: user@hostname:path# or user@hostname:path$
const promptPattern = /^[\w-]+@[\w-]+[^\s]*[:#$]\s+/;
const lines = allText.split("\n");
const segments: string[] = [];
let currentSegment: string[] = [];
lines.forEach((line) => {
const hasPrompt = promptPattern.test(line);
if (hasPrompt && currentSegment.length > 0) {
// Found a new prompt, save current segment and start new one
segments.push(currentSegment.join("\n"));
currentSegment = [line];
} else {
// Add line to current segment
currentSegment.push(line);
}
});
// Add the last segment
if (currentSegment.length > 0) {
segments.push(currentSegment.join("\n"));
}
// Filter out empty segments and convert to aggregated events
const validSegments = segments.filter((seg) => seg.trim().length > 0);
return validSegments.map((segment) => ({
timestamp: outputEvents[0].timestamp,
eventType: "output",
data: segment,
elapsedTime: outputEvents[0].elapsedTime,
eventCount: Math.ceil(outputEvents.length / validSegments.length)
}));
};
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
faBoxOpen,
faChevronDown,
@@ -25,18 +25,19 @@ import {
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
import { PAM_RESOURCE_TYPE_MAP, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
type Props = {
session: TPamSession;
search: string;
filteredCommandLogs: TPamSession["commandLogs"];
filteredLogs: TPamSession["logs"];
};
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
export const PamSessionRow = ({ session, search, filteredLogs }: Props) => {
const router = useRouter();
const [showAllLogs, setShowAllLogs] = useState(false);
@@ -55,8 +56,24 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
// Check if logs are terminal events and aggregate them
const processedLogs = useMemo(() => {
if (filteredLogs.length === 0) return [];
// Check if first log is a terminal event
const isTerminalEvents = "data" in filteredLogs[0];
if (isTerminalEvents) {
// Aggregate terminal events for better display
return aggregateTerminalEvents(filteredLogs as TTerminalEvent[]);
}
// Return command logs as-is
return filteredLogs;
}, [filteredLogs]);
const LOGS_TO_SHOW = 5;
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
const logsToShow = showAllLogs ? processedLogs : processedLogs.slice(0, LOGS_TO_SHOW);
return (
<>
@@ -135,8 +152,8 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.PamResources}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.PamSessions}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -157,32 +174,56 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
</Td>
</Tr>
{filteredCommandLogs.length > 0 && (
{filteredLogs.length > 0 && (
<Tr>
<Td colSpan={5} className="py-3 text-xs">
{logsToShow.map((log) => {
const formattedInput = formatLogContent(log.input);
// Handle command logs (database sessions)
if ("input" in log && "output" in log) {
const formattedInput = formatLogContent(log.input);
return (
<div
key={`${id}-log-${log.timestamp}`}
className="mb-4 flex flex-col gap-1 last:mb-0"
>
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
return (
<div
key={`${id}-log-${log.timestamp}`}
className="mb-4 flex flex-col gap-1 last:mb-0"
>
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="font-mono break-all whitespace-pre-wrap">
<HighlightText text={formattedInput} highlight={search} />
<div className="font-mono break-all whitespace-pre-wrap">
<HighlightText text={formattedInput} highlight={search} />
</div>
<div className="font-mono text-bunker-300">
<HighlightText text={log.output.trim()} highlight={search} />
</div>
</div>
<div className="font-mono text-bunker-300">
<HighlightText text={log.output.trim()} highlight={search} />
);
}
// Handle aggregated terminal events (SSH sessions)
if ("data" in log && typeof log.data === "string") {
return (
<div
key={`${id}-log-${log.timestamp}`}
className="mb-4 flex flex-col gap-1 last:mb-0"
>
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="font-mono break-all whitespace-pre-wrap text-bunker-300">
<HighlightText text={log.data.trim()} highlight={search} />
</div>
</div>
</div>
);
);
}
return null;
})}
{filteredCommandLogs.length > LOGS_TO_SHOW && (
{filteredLogs.length > LOGS_TO_SHOW && (
<div className="mt-2">
<Button
variant="link"
@@ -193,7 +234,7 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
>
{showAllLogs
? "Show less"
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
: `Show ${filteredLogs.length - LOGS_TO_SHOW} more log${filteredLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
</Button>
</div>
)}
@@ -113,7 +113,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
id,
resourceName,
userId,
commandLogs
logs
} = session;
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
@@ -131,11 +131,25 @@ export const PamSessionsTable = ({ sessions }: Props) => {
const filteredLogs =
searchValue.length >= 2
? commandLogs.filter(
(log) =>
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
)
? logs.filter((log) => {
// Handle command logs (database sessions)
if ("input" in log && "output" in log) {
return (
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
);
}
// Handle terminal events (SSH sessions)
if ("data" in log) {
try {
const decodedData = atob(log.data);
return decodedData.toLowerCase().includes(searchValue);
} catch {
return false;
}
}
return false;
})
: [];
return {
@@ -385,7 +399,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
key={session.id}
session={session}
search={search.trim().toLowerCase()}
filteredCommandLogs={filteredLogs}
filteredLogs={filteredLogs}
/>
))}
</TBody>
@@ -90,7 +90,7 @@ export const SecretSyncsTab = () => {
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-1.5">
<p className="text-lg font-medium text-mineshaft-100">Secret Syncs</p>
@@ -959,9 +959,9 @@ export const OverviewPage = () => {
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="flex flex-row items-center justify-center space-x-2">
<div className="flex flex-row flex-wrap items-center gap-2">
{isTableFiltered && (
<Button
variant="plain"
@@ -294,7 +294,7 @@ export const AccessApprovalRequest = ({
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Access Requests</p>
@@ -240,7 +240,7 @@ export const ApprovalPolicyList = ({ projectId }: IProps) => {
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Policies</p>
@@ -690,7 +690,7 @@ export const ActionBar = ({
return (
<>
<div className="mt-4 flex items-center space-x-2">
<div className="mt-4 flex flex-wrap items-center gap-2">
<SecretSearchInput
isSingleEnv
className="w-2/5"
@@ -40,12 +40,13 @@ export const SecretSharingSection = () => {
<p className="mb-3 text-xl font-medium">Allow Secret Sharing</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<div>
<Checkbox
id="secretSharing"
isDisabled={!isAllowed || isLoading}
isChecked={currentProject?.secretSharing ?? true}
onCheckedChange={(state) => handleToggle(state as boolean)}
allowMultilineLabel
>
This feature enables your project members to securely share secrets.
</Checkbox>
@@ -40,12 +40,13 @@ export const SecretSnapshotsLegacySection = () => {
<p className="mb-3 text-xl font-medium">Show Secret Snapshots ( legacy )</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<div>
<Checkbox
id="showSnapshotsLegacy"
isDisabled={!isAllowed || isLoading}
isChecked={currentProject?.showSnapshotsLegacy ?? false}
onCheckedChange={(state) => handleToggle(state as boolean)}
allowMultilineLabel
>
This feature enables your project members to view secret snapshots in the legacy
format.