Merge branch 'main' into ENG-3955

This commit is contained in:
x032205
2025-10-21 20:30:09 -04:00
438 changed files with 25651 additions and 5604 deletions
@@ -63,14 +63,14 @@ export const WishForm = () => {
open={isOpen}
>
<PopoverTrigger asChild>
<div className="text-md mb-3 w-full pl-5 duration-200 hover:text-mineshaft-200">
<div className="mb-3 w-full cursor-pointer pl-5 text-sm whitespace-nowrap text-mineshaft-400 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faRocketchat} className="mr-2" />
Request a feature
</div>
</PopoverTrigger>
<PopoverContent
hideCloseBtn
align="start"
align="end"
alignOffset={20}
className="mb-1 w-auto border border-mineshaft-600 bg-mineshaft-900 p-4 drop-shadow-2xl"
sticky="always"
+3 -3
View File
@@ -20,11 +20,11 @@ const badgeVariants = cva(
success: "bg-green/20 text-green",
org: "bg-org-v1/20 text-org-v1 [&_svg]:text-org-v1 flex items-center opacity-100 hover:bg-org-v1/10 [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap",
namespace:
"bg-namespace-v1/20 text-namespace-v1 [&_svg]:text-namespace-v1 flex opacity-100 hover:bg-namespace-v1/10 items-center [&_svg]:size-3.5 gap-x-1 w-min whitespace-nowrap",
"bg-namespace-v1/20 text-namespace-v1 [&_svg]:text-namespace-v1 flex opacity-100 hover:bg-namespace-v1/10 items-center [&_svg]:size-3.5 gap-x-1.5 w-min whitespace-nowrap",
project:
"bg-primary/10 text-primary [&_svg]:text-primary opacity-100 hover:bg-primary/10 flex items-center [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap",
"bg-primary/10 text-primary [&_svg]:text-primary opacity-100 hover:bg-primary/10 flex items-center [&_svg]:size-3 w-min gap-x-1.5 whitespace-nowrap",
instance:
"bg-mineshaft-200/20 text-mineshaft-200 [&_svg]:text-mineshaft-200 opacity-100 hover:bg-mineshaft-200/20 flex items-center [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap"
"bg-mineshaft-200/20 text-mineshaft-200 [&_svg]:text-mineshaft-200 opacity-100 hover:bg-mineshaft-200/20 flex items-center [&_svg]:size-3 gap-x-1.5 w-min whitespace-nowrap"
}
}
}
@@ -120,10 +120,16 @@ export type TBreadcrumbFormat =
icon?: ReactNode;
};
const BreadcrumbContainer = ({ breadcrumbs }: { breadcrumbs: TBreadcrumbFormat[] }) => (
<div className="mx-auto max-w-7xl text-white">
<Breadcrumb>
<BreadcrumbList>
const BreadcrumbContainer = ({
breadcrumbs,
className
}: {
breadcrumbs: TBreadcrumbFormat[];
className?: string;
}) => (
<div className={twMerge("mx-auto max-w-8xl overflow-hidden text-white", className)}>
<Breadcrumb className="overflow-hidden">
<BreadcrumbList className="overflow-hidden">
{(breadcrumbs as TBreadcrumbFormat[]).map((el, index) => {
const isNotLastCrumb = index + 1 !== breadcrumbs.length;
const BreadcrumbSegment = isNotLastCrumb ? BreadcrumbLink : BreadcrumbPage;
@@ -165,8 +171,8 @@ const BreadcrumbContainer = ({ breadcrumbs }: { breadcrumbs: TBreadcrumbFormat[]
const Component = el.component;
return (
<React.Fragment key={`breadcrumb-group-${index + 1}`}>
<BreadcrumbItem>
<BreadcrumbSegment>
<BreadcrumbItem className="overflow-hidden">
<BreadcrumbSegment className="overflow-hidden">
<Component />
</BreadcrumbSegment>
</BreadcrumbItem>
@@ -5,29 +5,48 @@ import { ReactNode } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { Badge } from "@app/components/v2";
import { BadgeProps } from "@app/components/v2/Badge/Badge";
import { ProjectType } from "@app/hooks/api/projects/types";
type Props = {
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
className?: string;
scope: "org" | "project" | "namespace" | "instance";
scope: "org" | "namespace" | "instance" | ProjectType | null;
};
const SCOPE_NAME: Record<NonNullable<Props["scope"]>, { label: string; icon: IconDefinition }> = {
org: { label: "Organization", icon: faGlobe },
project: { label: "Project", icon: faCube },
namespace: { label: "Namespace", icon: faCubes },
[ProjectType.SecretManager]: { label: "Project", icon: faCube },
[ProjectType.CertificateManager]: { label: "Project", icon: faCube },
[ProjectType.SSH]: { label: "Project", icon: faCube },
[ProjectType.KMS]: { label: "Project", icon: faCube },
[ProjectType.PAM]: { label: "Project", icon: faCube },
[ProjectType.SecretScanning]: { label: "Project", icon: faCube },
namespace: { label: "Sub-Organization", icon: faCubes },
instance: { label: "Server", icon: faServer }
};
const SCOPE_VARIANT: Record<NonNullable<Props["scope"]>, BadgeProps["variant"]> = {
org: "org",
[ProjectType.SecretManager]: "project",
[ProjectType.CertificateManager]: "project",
[ProjectType.SSH]: "project",
[ProjectType.KMS]: "project",
[ProjectType.PAM]: "project",
[ProjectType.SecretScanning]: "project",
namespace: "namespace",
instance: "instance"
};
export const PageHeader = ({ title, description, children, className, scope }: Props) => (
<div className={twMerge("mb-4 w-full", className)}>
<div className={twMerge("mb-10 w-full", className)}>
<div className="flex w-full justify-between">
<div className="mr-4 flex w-full items-center">
<h1 className="text-3xl font-medium text-white capitalize">{title}</h1>
{scope && (
<Badge variant={scope} className="mt-1 ml-2.5">
<Badge variant={SCOPE_VARIANT[scope]} className="mt-1 ml-2.5">
<FontAwesomeIcon icon={SCOPE_NAME[scope].icon} />
{SCOPE_NAME[scope].label}
</Badge>
+32 -5
View File
@@ -1,10 +1,19 @@
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { twMerge } from "tailwind-merge";
export type TabsProps = TabsPrimitive.TabsProps;
export const Tabs = ({ className, children, ...props }: TabsProps) => (
<TabsPrimitive.Root className={twMerge("flex flex-col", className)} {...props}>
<TabsPrimitive.Root
className={twMerge(
"flex",
className,
props.orientation === "vertical" ? "flex-col xl:flex-row xl:gap-x-12" : "flex-col"
)}
{...props}
>
{children}
</TabsPrimitive.Root>
);
@@ -13,7 +22,11 @@ export type TabListProps = TabsPrimitive.TabsListProps;
export const TabList = ({ className, children, ...props }: TabListProps) => (
<TabsPrimitive.List
className={twMerge("flex shrink-0 border-b-2 border-mineshaft-800", className)}
className={twMerge(
"no-scrollbar flex shrink-0 overflow-auto border-b-2 border-mineshaft-800",
"data-[orientation=vertical]:xl:flex-col data-[orientation=vertical]:xl:items-start data-[orientation=vertical]:xl:gap-y-6 data-[orientation=vertical]:xl:border-b-0",
className
)}
{...props}
>
{children}
@@ -26,18 +39,29 @@ export const Tab = ({
className,
children,
variant = "project",
icon,
...props
}: TabProps & { variant?: "project" | "namespace" | "org" }) => (
}: TabProps & {
icon?: IconDefinition;
variant?: "project" | "namespace" | "org" | "instance";
}) => (
<TabsPrimitive.Trigger
className={twMerge(
"flex h-10 items-center justify-center px-3 text-sm font-medium text-mineshaft-400 transition-all select-none first:rounded-tl-md last:rounded-tr-md hover:text-mineshaft-200 data-[state=active]:border-b data-[state=active]:text-white",
"flex h-10 cursor-pointer items-center justify-center border-transparent",
"px-3 text-sm font-medium whitespace-nowrap text-mineshaft-400 transition-all select-none",
"data-[orientation=vertical]:xl:h-5 data-[orientation=vertical]:xl:border-b-0 data-[orientation=vertical]:xl:border-l",
"border-b hover:text-mineshaft-200",
"data-[state=active]:border-mineshaft-400 data-[state=active]:text-white",
"hover:border-mineshaft-400",
variant === "project" && "data-[state=active]:border-primary",
variant === "namespace" && "data-[state=active]:border-namespace-v1",
variant === "org" && "data-[state=active]:border-org-v1",
variant === "instance" && "data-[state=active]:border-mineshaft-300",
className
)}
{...props}
>
{icon && <FontAwesomeIcon icon={icon} className="mr-2" size="xs" />}
{children}
</TabsPrimitive.Trigger>
);
@@ -46,7 +70,10 @@ export type TabPanelProps = TabsPrimitive.TabsContentProps;
export const TabPanel = ({ className, children, ...props }: TabPanelProps) => (
<TabsPrimitive.Content
className={twMerge("grow rounded-br-md rounded-bl-md py-5 outline-hidden", className)}
className={twMerge(
"grow rounded-br-md rounded-bl-md py-5 outline-hidden data-[orientation=vertical]:xl:overflow-x-hidden data-[orientation=vertical]:xl:py-0",
className
)}
{...props}
>
{children}
+13
View File
@@ -24,6 +24,8 @@ apiRequest.interceptors.request.use((config) => {
const token = getAuthToken();
const providerAuthToken = SecurityClient.getProviderAuthToken();
const params = new URLSearchParams(window.location.search);
if (config.headers) {
if (signupTempToken) {
// eslint-disable-next-line no-param-reassign
@@ -38,6 +40,17 @@ apiRequest.interceptors.request.use((config) => {
// eslint-disable-next-line no-param-reassign
config.headers.Authorization = `Bearer ${providerAuthToken}`;
}
const rootOrgHeader = config.headers.get("x-root-org");
if (rootOrgHeader) {
config.headers.delete("x-root-org");
} else {
const subOrganization = params.get("subOrganization");
if (subOrganization) {
config.headers.set("x-infisical-org", subOrganization);
}
}
}
return config;
-4
View File
@@ -33,10 +33,6 @@ export const ROUTE_PATHS = Object.freeze({
"/organization/secret-sharing",
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/"
),
SecretSharingSettings: setRoute(
"/organization/secret-sharing/settings",
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings"
),
SettingsPage: setRoute(
"/organization/settings",
"/_authenticate/_inject-org-details/_org-layout/organization/settings/"
@@ -62,7 +62,8 @@ export enum OrgPermissionSubjects {
SecretShare = "secret-share",
GithubOrgSync = "github-org-sync",
GithubOrgSyncManual = "github-org-sync-manual",
MachineIdentityAuthTemplate = "machine-identity-auth-template"
MachineIdentityAuthTemplate = "machine-identity-auth-template",
SubOrganization = "sub-organization"
}
export enum OrgPermissionAdminConsoleAction {
@@ -112,6 +113,11 @@ export enum OrgPermissionGroupActions {
RemoveMembers = "remove-members"
}
export enum OrgPermissionSubOrgActions {
Create = "create",
DirectAccess = "direct-access"
}
export type AppConnectionSubjectFields = {
connectionId: string;
};
@@ -151,6 +157,7 @@ export type OrgPermissionSet =
| OrgPermissionSubjects.AppConnections
| (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields)
)
];
]
| [OrgPermissionSubOrgActions, OrgPermissionSubjects.SubOrganization];
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useRouteContext, useSearch } from "@tanstack/react-router";
import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries";
@@ -9,11 +10,29 @@ export const useOrganization = () => {
select: (el) => el.organizationId
});
const subOrganization = useSearch({
strict: false,
select: (el) => el?.subOrganization
});
const { data: currentOrg } = useSuspenseQuery({
queryKey: organizationKeys.getOrgById(organizationId),
queryKey: organizationKeys.getOrgById(organizationId, subOrganization || "root"),
queryFn: () => fetchOrganizationById(organizationId),
staleTime: Infinity
});
return { currentOrg };
const org = useMemo(
() => ({
currentOrg: {
...currentOrg,
id: currentOrg?.subOrganization?.id || currentOrg?.id,
parentOrgId: currentOrg.id
},
isSubOrganization: Boolean(currentOrg.subOrganization),
isRootOrganization: !currentOrg.subOrganization
}),
[currentOrg, subOrganization]
);
return org;
};
@@ -4,6 +4,7 @@ export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,
@@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions {
ListCerts = "list-certs"
}
export enum ProjectPermissionCertificateProfileActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
@@ -293,6 +301,7 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
PkiSubscribers = "pki-subscribers",
CertificateProfiles = "certificate-profiles",
Kms = "kms",
Cmek = "cmek",
SecretSyncs = "secret-syncs",
@@ -470,6 +479,7 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.PkiSubscribers> & PkiSubscriberSubjectFields)
)
]
| [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
+1
View File
@@ -15,6 +15,7 @@ export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,
+1 -1
View File
@@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
case ProjectType.SecretManager:
return "/projects/secret-management/$projectId/overview" as const;
case ProjectType.CertificateManager:
return "/projects/cert-management/$projectId/subscribers" as const;
return "/projects/cert-management/$projectId/policies" as const;
case ProjectType.SecretScanning:
return `/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM:
@@ -24,7 +24,7 @@ export const withPermission = <T extends object, J extends TOrgPermission>(
// akhilmhdh: Set as any due to casl/react ts type bug
// REASON: casl due to its type checking can't seem to union even if union intersection is applied
if (permission.cannot(action as any, subject)) {
if (permission.cannot(action as any, subject as any)) {
return (
<div
className={twMerge(
+3
View File
@@ -2,8 +2,10 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from
export {
useCreateCa,
useCreateCertificate,
useCreateCertificateV3,
useDeleteCa,
useImportCaCertificate,
useOrderCertificateWithProfile,
useRenewCa,
useSignIntermediate,
useUpdateCa
@@ -21,3 +23,4 @@ export {
useListCasByTypeAndProjectId,
useListExternalCasByProjectId
} from "./queries";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";
+44
View File
@@ -9,9 +9,13 @@ import {
TCreateCertificateAuthorityDTO,
TCreateCertificateDTO,
TCreateCertificateResponse,
TCreateCertificateV3DTO,
TCreateCertificateV3Response,
TDeleteCertificateAuthorityDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
TOrderCertificateDTO,
TOrderCertificateResponse,
TRenewCaDTO,
TRenewCaResponse,
TSignIntermediateDTO,
@@ -148,6 +152,46 @@ export const useCreateCertificate = () => {
});
};
export const useCreateCertificateV3 = () => {
const queryClient = useQueryClient();
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TCreateCertificateV3Response>(
"/api/v3/certificates/issue-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles"]
});
}
});
};
export const useOrderCertificateWithProfile = () => {
const queryClient = useQueryClient();
return useMutation<TOrderCertificateResponse, object, TOrderCertificateDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TOrderCertificateResponse>(
"/api/v3/certificates/order-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
}
});
};
export const useRenewCa = () => {
const queryClient = useQueryClient();
return useMutation<TRenewCaResponse, object, TRenewCaDTO>({
+79 -1
View File
@@ -155,7 +155,7 @@ export type TCreateCertificateDTO = {
pkiCollectionId?: string;
friendlyName?: string;
commonName: string;
altNames: string; // sans
subjectAltNames: string; // sans
ttl: string; // string compatible with ms
notBefore?: string;
notAfter?: string;
@@ -171,6 +171,84 @@ export type TCreateCertificateResponse = {
serialNumber: string;
};
export type TCreateCertificateV3DTO = {
projectSlug: string;
profileId: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName?: string;
organization?: string;
organizationUnit?: string;
locality?: string;
state?: string;
country?: string;
email?: string;
streetAddress?: string;
postalCode?: string;
subjectAltNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
export type TCreateCertificateV3Response = TCreateCertificateResponse & {
projectId: string;
profileName: string;
certificateId: string;
};
export type TOrderCertificateDTO = {
projectSlug: string;
profileId: string;
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
}>;
ttl: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
notBefore?: string;
notAfter?: string;
commonName?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
export type TOrderCertificateResponse = {
orderId: string;
status: "pending" | "processing" | "valid" | "invalid";
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
status: "pending" | "processing" | "valid" | "invalid";
}>;
authorizations: Array<{
identifier: {
type: "dns" | "ip";
value: string;
};
status: "pending" | "processing" | "valid" | "invalid";
expires?: string;
challenges: Array<{
type: string;
status: "pending" | "processing" | "valid" | "invalid";
url: string;
token: string;
validated?: string;
error?: string | Error;
}>;
}>;
certificate?: string;
privateKey?: string;
expires: string;
notBefore: string;
notAfter: string;
};
export type TRenewCaDTO = {
projectSlug: string;
caId: string;
@@ -0,0 +1,14 @@
export {
useCreateCertificateProfile,
useDeleteCertificateProfile,
useUpdateCertificateProfile
} from "./mutations";
export {
certificateProfileKeys,
useGetCertificateProfileById,
useGetCertificateProfileBySlug,
useGetProfileCertificates,
useGetProfileMetrics,
useListCertificateProfiles
} from "./queries";
export type * from "./types";
@@ -0,0 +1,71 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { certificateProfileKeys } from "./queries";
import {
TCertificateProfile,
TCreateCertificateProfileDTO,
TDeleteCertificateProfileDTO,
TUpdateCertificateProfileDTO
} from "./types";
export const useCreateCertificateProfile = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateProfile, object, TCreateCertificateProfileDTO>({
mutationFn: async (data) => {
const { data: response } = await apiRequest.post<{
certificateProfile: TCertificateProfile;
}>("/api/v1/pki/certificate-profiles", data);
return response.certificateProfile;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.list({ projectId })
});
}
});
};
export const useUpdateCertificateProfile = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateProfile, object, TUpdateCertificateProfileDTO>({
mutationFn: async ({ profileId, ...data }) => {
const { data: response } = await apiRequest.patch<{
certificateProfile: TCertificateProfile;
}>(`/api/v1/pki/certificate-profiles/${profileId}`, data);
return response.certificateProfile;
},
onSuccess: (profile, { profileId }) => {
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.list({ projectId: profile.projectId })
});
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.getById(profileId)
});
}
});
};
export const useDeleteCertificateProfile = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateProfile, object, TDeleteCertificateProfileDTO>({
mutationFn: async ({ profileId }) => {
const { data: response } = await apiRequest.delete<{
certificateProfile: TCertificateProfile;
}>(`/api/v1/pki/certificate-profiles/${profileId}`);
return response.certificateProfile;
},
onSuccess: (profile, { profileId }) => {
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.list({ projectId: profile.projectId })
});
queryClient.removeQueries({
queryKey: certificateProfileKeys.getById(profileId)
});
}
});
};
@@ -0,0 +1,162 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
TCertificateProfile,
TCertificateProfileMetrics,
TCertificateProfileWithDetails,
TGetCertificateProfileByIdDTO,
TGetCertificateProfileBySlugDTO,
TGetProfileCertificatesDTO,
TGetProfileMetricsDTO,
TListCertificateProfilesDTO,
TProfileCertificate
} from "./types";
export const certificateProfileKeys = {
list: (params: {
projectId: string;
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: string;
expiringDays?: number;
}) => ["certificate-profiles", "list", params],
getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId],
getBySlug: (projectId: string, slug: string) => [
"certificate-profiles",
"get-by-slug",
projectId,
slug
],
getCertificates: (profileId: string, params?: Omit<TGetProfileCertificatesDTO, "profileId">) => [
"certificate-profiles",
"certificates",
profileId,
params
],
getMetrics: (profileId: string, params?: Omit<TGetProfileMetricsDTO, "profileId">) => [
"certificate-profiles",
"metrics",
profileId,
params
]
};
export const useListCertificateProfiles = ({
projectId,
limit = 20,
offset = 0,
search,
includeMetrics = false,
includeConfigs = false,
enrollmentType,
expiringDays = 7
}: TListCertificateProfilesDTO) => {
return useQuery({
queryKey: certificateProfileKeys.list({
projectId,
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
}),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateProfiles: TCertificateProfile[];
totalCount: number;
}>("/api/v1/pki/certificate-profiles", {
params: {
projectId,
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
}
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getById(profileId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateProfile: TCertificateProfileWithDetails;
}>(`/api/v1/pki/certificate-profiles/${profileId}`);
return data.certificateProfile;
},
enabled: Boolean(profileId)
});
};
export const useGetCertificateProfileBySlug = ({
projectId,
slug
}: TGetCertificateProfileBySlugDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getBySlug(projectId, slug),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateProfile: TCertificateProfile;
}>(`/api/v1/pki/certificate-profiles/slug/${slug}`, {
params: { projectId }
});
return data.certificateProfile;
},
enabled: Boolean(projectId && slug)
});
};
export const useGetProfileCertificates = ({
profileId,
offset = 0,
limit = 20,
status,
search
}: TGetProfileCertificatesDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificates: TProfileCertificate[];
}>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, {
params: {
offset,
limit,
status,
search
}
});
return data.certificates;
},
enabled: Boolean(profileId)
});
};
export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }),
queryFn: async () => {
const { data } = await apiRequest.get<{
metrics: TCertificateProfileMetrics;
}>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, {
params: { expiringDays }
});
return data.metrics;
},
enabled: Boolean(profileId)
});
};
@@ -0,0 +1,130 @@
export type TCertificateProfile = {
id: string;
projectId: string;
caId: string;
certificateTemplateId: string;
slug: string;
description?: string;
enrollmentType: "api" | "est";
estConfigId?: string;
apiConfigId?: string;
createdAt: string;
updatedAt: string;
metrics?: TCertificateProfileMetrics;
};
export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateAuthority?: {
id: string;
projectId: string;
status: string;
name: string;
};
certificateTemplate?: {
id: string;
projectId: string;
name: string;
description?: string;
};
estConfig?: {
id: string;
disableBootstrapCaValidation: boolean;
passphrase: string;
caChain: string;
};
apiConfig?: {
id: string;
autoRenew: boolean;
autoRenewDays?: number;
};
};
export type TCreateCertificateProfileDTO = {
projectId: string;
caId: string;
certificateTemplateId: string;
slug: string;
description?: string;
enrollmentType: "api" | "est";
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase: string;
caChain?: string;
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
};
};
export type TUpdateCertificateProfileDTO = {
profileId: string;
slug?: string;
description?: string;
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase?: string;
caChain?: string;
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
};
};
export type TDeleteCertificateProfileDTO = {
profileId: string;
};
export type TListCertificateProfilesDTO = {
projectId: string;
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: "api" | "est";
expiringDays?: number;
};
export type TGetCertificateProfileByIdDTO = {
profileId: string;
};
export type TGetCertificateProfileBySlugDTO = {
projectId: string;
slug: string;
};
export type TCertificateProfileMetrics = {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
};
export type TProfileCertificate = {
id: string;
serialNumber: string;
cn: string;
status: string;
notBefore: string;
notAfter: string;
isRevoked: boolean;
createdAt: string;
};
export type TGetProfileCertificatesDTO = {
profileId: string;
offset?: number;
limit?: number;
status?: "active" | "expired" | "revoked";
search?: string;
};
export type TGetProfileMetricsDTO = {
profileId: string;
expiringDays?: number;
};
@@ -7,13 +7,17 @@ import { projectKeys } from "../projects";
import { certTemplateKeys } from "./queries";
import {
TCertificateTemplate,
TCertificateTemplateV2WithPolicies,
TCreateCertificateTemplateDTO,
TCreateCertificateTemplateV2DTO,
TCreateCertificateTemplateV2WithPoliciesDTO,
TCreateEstConfigDTO,
TDeleteCertificateTemplateDTO,
TDeleteCertificateTemplateV2DTO,
TDeleteCertificateTemplateV2WithPoliciesDTO,
TUpdateCertificateTemplateDTO,
TUpdateCertificateTemplateV2DTO,
TUpdateCertificateTemplateV2WithPoliciesDTO,
TUpdateEstConfigDTO
} from "./types";
@@ -163,3 +167,72 @@ export const useUpdateEstConfig = () => {
}
});
};
export const useCreateCertificateTemplateV2WithPolicies = () => {
const queryClient = useQueryClient();
return useMutation<
TCertificateTemplateV2WithPolicies,
object,
TCreateCertificateTemplateV2WithPoliciesDTO
>({
mutationFn: async (data) => {
const { data: response } = await apiRequest.post<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>("/api/v2/certificate-templates", data);
return response.certificateTemplate;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: certTemplateKeys.listTemplatesV2({ projectId })
});
}
});
};
export const useUpdateCertificateTemplateV2WithPolicies = () => {
const queryClient = useQueryClient();
return useMutation<
TCertificateTemplateV2WithPolicies,
object,
TUpdateCertificateTemplateV2WithPoliciesDTO
>({
mutationFn: async ({ templateId, ...data }) => {
const { data: response } = await apiRequest.patch<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>(`/api/v2/certificate-templates/${templateId}`, data);
return response.certificateTemplate;
},
onSuccess: (template, { templateId }) => {
queryClient.invalidateQueries({
queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId })
});
queryClient.invalidateQueries({
queryKey: certTemplateKeys.getTemplateV2ById(templateId)
});
}
});
};
export const useDeleteCertificateTemplateV2WithPolicies = () => {
const queryClient = useQueryClient();
return useMutation<
TCertificateTemplateV2WithPolicies,
object,
TDeleteCertificateTemplateV2WithPoliciesDTO
>({
mutationFn: async ({ templateId }) => {
const { data: response } = await apiRequest.delete<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>(`/api/v2/certificate-templates/${templateId}`);
return response.certificateTemplate;
},
onSuccess: (template, { templateId }) => {
queryClient.invalidateQueries({
queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId })
});
queryClient.removeQueries({
queryKey: certTemplateKeys.getTemplateV2ById(templateId)
});
}
});
};
@@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request";
import {
TCertificateTemplate,
TCertificateTemplateV2,
TCertificateTemplateV2WithPolicies,
TEstConfig,
TListCertificateTemplatesDTO
TGetCertificateTemplateV2ByIdDTO,
TListCertificateTemplatesDTO,
TListCertificateTemplatesV2DTO
} from "./types";
export const certTemplateKeys = {
@@ -16,7 +19,16 @@ export const certTemplateKeys = {
projectId,
el
],
getEstConfig: (id: string) => [{ id }, "cert-template-est-config"]
getEstConfig: (id: string) => [{ id }, "cert-template-est-config"],
listTemplatesV2: ({
projectId,
...el
}: {
limit?: number;
offset?: number;
projectId: string;
}) => ["list-templates-v2", projectId, el],
getTemplateV2ById: (id: string) => ["cert-template-v2", id]
};
export const useGetCertTemplate = (id: string) => {
@@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => {
enabled: Boolean(certificateTemplateId)
});
};
export const useListCertificateTemplatesV2 = ({
projectId,
limit = 20,
offset = 0
}: TListCertificateTemplatesV2DTO) => {
return useQuery({
queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateTemplates: TCertificateTemplateV2WithPolicies[];
totalCount: number;
}>("/api/v2/certificate-templates", {
params: {
projectId,
limit,
offset
}
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetCertificateTemplateV2ById = ({
templateId
}: TGetCertificateTemplateV2ByIdDTO) => {
return useQuery({
queryKey: certTemplateKeys.getTemplateV2ById(templateId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>(`/api/v2/certificate-templates/${templateId}`);
return data.certificateTemplate;
},
enabled: Boolean(templateId)
});
};
@@ -121,3 +121,90 @@ export type TListCertificateTemplatesDTO = {
offset?: number;
projectId: string;
};
export type TCertificateTemplateV2Policy = {
subject?: Array<{
type: "common_name" | "organization" | "country";
allowed?: string[];
required?: string[];
denied?: string[];
}>;
sans?: Array<{
type: "dns_name" | "ip_address" | "email" | "uri";
allowed?: string[];
required?: string[];
denied?: string[];
}>;
keyUsages?: {
allowed?: string[];
required?: string[];
denied?: string[];
};
extendedKeyUsages?: {
allowed?: string[];
required?: string[];
denied?: string[];
};
algorithms?: {
signature?: Array<
"SHA256-RSA" | "SHA384-RSA" | "SHA512-RSA" | "SHA256-ECDSA" | "SHA384-ECDSA" | "SHA512-ECDSA"
>;
keyAlgorithm?: Array<"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384">;
};
validity?: {
max?: string;
};
};
export type TCertificateTemplateV2WithPolicies = {
id: string;
projectId: string;
name: string;
description?: string;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
createdAt: string;
updatedAt: string;
};
export type TCreateCertificateTemplateV2WithPoliciesDTO = {
projectId: string;
name: string;
description?: string;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
};
export type TUpdateCertificateTemplateV2WithPoliciesDTO = {
templateId: string;
name?: string;
description?: string;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
};
export type TDeleteCertificateTemplateV2WithPoliciesDTO = {
templateId: string;
};
export type TListCertificateTemplatesV2DTO = {
projectId: string;
limit?: number;
offset?: number;
};
export type TGetCertificateTemplateV2ByIdDTO = {
templateId: string;
};
@@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => {
export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = {
[CertKeyAlgorithm.RSA_2048]: "RSA 2048",
[CertKeyAlgorithm.RSA_3072]: "RSA 3072",
[CertKeyAlgorithm.RSA_4096]: "RSA 4096",
[CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256",
[CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384"
@@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = {
export const certKeyAlgorithms = [
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 },
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 },
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 },
{
label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256],
@@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [
{ value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" },
{ value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" }
] as const;
export const SIGNATURE_ALGORITHMS_OPTIONS = [
{ value: "RSA-SHA256", label: "RSA-SHA256" },
{ value: "RSA-SHA384", label: "RSA-SHA384" },
{ value: "RSA-SHA512", label: "RSA-SHA512" },
{ value: "ECDSA-SHA256", label: "ECDSA-SHA256" },
{ value: "ECDSA-SHA384", label: "ECDSA-SHA384" },
{ value: "ECDSA-SHA512", label: "ECDSA-SHA512" }
] as const;
+16 -15
View File
@@ -5,6 +5,7 @@ export enum CertStatus {
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
RSA_3072 = "RSA_3072",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
@@ -24,22 +25,22 @@ export enum CrlReason {
}
export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature",
KEY_ENCIPHERMENT = "keyEncipherment",
NON_REPUDIATION = "nonRepudiation",
DATA_ENCIPHERMENT = "dataEncipherment",
KEY_AGREEMENT = "keyAgreement",
KEY_CERT_SIGN = "keyCertSign",
CRL_SIGN = "cRLSign",
ENCIPHER_ONLY = "encipherOnly",
DECIPHER_ONLY = "decipherOnly"
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsage {
CLIENT_AUTH = "clientAuth",
SERVER_AUTH = "serverAuth",
CODE_SIGNING = "codeSigning",
EMAIL_PROTECTION = "emailProtection",
TIMESTAMPING = "timeStamping",
OCSP_SIGNING = "ocspSigning"
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
TIMESTAMPING = "time_stamping",
OCSP_SIGNING = "ocsp_signing"
}
@@ -52,6 +52,10 @@ export const useRevokeCert = () => {
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
}
});
};
+1 -1
View File
@@ -7,7 +7,7 @@ export type TCertificate = {
status: CertStatus;
friendlyName: string;
commonName: string;
altNames: string;
subjectAltNames: string;
serialNumber: string;
notBefore: string;
notAfter: string;
@@ -20,6 +20,7 @@ export type Identity = {
createdAt: string;
updatedAt: string;
isInstanceAdmin?: boolean;
orgId: string;
};
export type IdentityAccessToken = {
+1
View File
@@ -48,6 +48,7 @@ export * from "./sshCertificateTemplates";
export * from "./sshHost";
export * from "./sshHostGroup";
export * from "./ssoConfig";
export * from "./subOrganizations";
export * from "./subscriptions";
export * from "./tags";
export * from "./trustedIps";
@@ -0,0 +1,6 @@
export { useCreateOrgIdentityMembership, useDeleteOrgIdentityMembership } from "./mutation";
export type {
TCreateOrgIdentityMembershipDTO,
TDeleteOrgIdentityMembershipDTO,
TOrgIdentityMembership
} from "./types";
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { identitiesKeys } from "../identities";
import {
TCreateOrgIdentityMembershipDTO,
TDeleteOrgIdentityMembershipDTO,
TOrgIdentityMembership
} from "./types";
export const useCreateOrgIdentityMembership = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ identityId, roles }: TCreateOrgIdentityMembershipDTO) => {
const { data } = await apiRequest.post<{ identityMembership: TOrgIdentityMembership }>(
`/api/v1/organization/identity-memberships/${identityId}`,
{ roles }
);
return data.identityMembership;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentities({ search: {} }) });
}
});
};
export const useDeleteOrgIdentityMembership = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ identityId }: TDeleteOrgIdentityMembershipDTO) => {
const { data } = await apiRequest.delete<{ identityMembership: TOrgIdentityMembership }>(
`/api/v1/organization/identity-memberships/${identityId}`
);
return data.identityMembership;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentities({ search: {} }) });
}
});
};
@@ -0,0 +1,32 @@
export enum TemporaryPermissionMode {
Relative = "relative"
}
export type TOrgIdentityMembership = {
id: string;
orgId: string;
identityId: string;
createdAt: string;
updatedAt: string;
};
export type TCreateOrgIdentityMembershipDTO = {
identityId: string;
roles: Array<
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: TemporaryPermissionMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
>;
};
export type TDeleteOrgIdentityMembershipDTO = {
identityId: string;
};
@@ -6,6 +6,7 @@ export {
useDeleteOrgById,
useDeleteOrgPmtMethod,
useDeleteOrgTaxId,
useGetAvailableOrgIdentities,
useGetIdentityMembershipOrgs,
useGetOrganizationGroups,
useGetOrganizations,
@@ -42,7 +42,9 @@ export const organizationKeys = {
[...organizationKeys.getOrgIdentityMemberships(orgId), params] as const,
getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const,
getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const,
getOrgById: (orgId: string) => ["organization", { orgId }]
getOrgById: (orgId: string, subOrg?: string) => ["organization", { orgId, subOrg }],
getAvailableIdentities: () => ["available-identities"],
getAvailableUsers: () => ["available-users"]
};
export const fetchOrganizations = async () => {
@@ -64,7 +66,9 @@ export const useGetOrganizations = () => {
export const fetchOrganizationById = async (id: string) => {
const {
data: { organization }
} = await apiRequest.get<{ organization: Organization }>(`/api/v1/organization/${id}`);
} = await apiRequest.get<{
organization: Organization & { subOrganization?: { id: string; name: string } };
}>(`/api/v1/organization/${id}`);
return organization;
};
@@ -572,3 +576,29 @@ export const useGetOrgIntegrationAuths = <TData = IntegrationAuth[],>(
select
});
};
export const useGetAvailableOrgIdentities = (enabled = true) =>
useQuery({
queryKey: organizationKeys.getAvailableIdentities(),
queryFn: async () => {
const { data } = await apiRequest.get<{ identities: { name: string; id: string }[] }>(
"/api/v1/organization/identities/available"
);
return data.identities;
},
enabled
});
export const useGetAvailableOrgUsers = (enabled = true) =>
useQuery({
queryKey: organizationKeys.getAvailableUsers(),
queryFn: async () => {
const { data } = await apiRequest.get<{
users: { username: string; id: string; firstName: string; lastName: string }[];
}>("/api/v1/organization/users/available");
return data.users;
},
enabled
});
@@ -2,6 +2,7 @@ export {
useCreateFolder,
useDeleteFolder,
useGetFoldersByEnv,
useGetOrCreateFolder,
useGetProjectFolders,
useUpdateFolder
} from "./queries";
@@ -140,6 +140,59 @@ export const useGetFoldersByEnv = ({
return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
};
export const useGetOrCreateFolder = () => {
const queryClient = useQueryClient();
return useMutation<TSecretFolder, object, TCreateFolderDTO>({
mutationFn: async (dto) => {
const { data: existingFolder } = await apiRequest.get<{ folders: TSecretFolder[] }>(
"/api/v2/folders",
{
params: {
projectId: dto.projectId,
environment: dto.environment,
path: dto.path || "/"
}
}
);
const folder = existingFolder.folders.find((f) => f.name === dto.name);
if (folder) return folder;
const { data } = await apiRequest.post("/api/v2/folders", {
...dto,
projectId: dto.projectId
});
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
});
queryClient.invalidateQueries({
queryKey: folderQueryKeys.getSecretFolders({ projectId, environment, path })
});
queryClient.invalidateQueries({
queryKey: secretSnapshotKeys.list({ projectId, environment, directory: path })
});
queryClient.invalidateQueries({
queryKey: secretSnapshotKeys.count({ projectId, environment, directory: path })
});
queryClient.invalidateQueries({
queryKey: commitKeys.count({ projectId, environment, directory: path })
});
queryClient.invalidateQueries({
queryKey: commitKeys.history({ projectId, environment, directory: path })
});
}
});
};
export const useCreateFolder = () => {
const queryClient = useQueryClient();
@@ -170,6 +223,9 @@ export const useCreateFolder = () => {
queryClient.invalidateQueries({
queryKey: commitKeys.count({ projectId, environment, directory: path })
});
queryClient.invalidateQueries({
queryKey: commitKeys.history({ projectId, environment, directory: path })
});
}
});
};
@@ -0,0 +1,8 @@
export { useCreateSubOrganization, useUpdateSubOrganization } from "./mutations";
export { subOrganizationsQuery } from "./queries";
export type {
TCreateSubOrganizationDTO,
TListSubOrganizationsDTO,
TSubOrganization,
TUpdateSubOrganizationDTO
} from "./types";
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { subOrganizationsQuery } from "./queries";
import { TCreateSubOrganizationDTO, TSubOrganization, TUpdateSubOrganizationDTO } from "./types";
export const useCreateSubOrganization = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TCreateSubOrganizationDTO) => {
const { data } = await apiRequest.post<{ organization: TSubOrganization }>(
"/api/v1/sub-organizations",
dto,
{
headers: { "x-root-org": "discard" } // akhi/scott: this just tells the request to use the root org ID header
}
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() });
}
});
};
export const useUpdateSubOrganization = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ subOrgId, name }: TUpdateSubOrganizationDTO) => {
const { data } = await apiRequest.patch<{ organization: TSubOrganization }>(
`/api/v1/sub-organizations/${subOrgId}`,
{ name }
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() });
}
});
};
@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TListSubOrganizationsDTO, TSubOrganization } from "./types";
export const subOrganizationsQuery = {
allKey: () => ["sub-organizations"] as const,
listKey: (params?: TListSubOrganizationsDTO) =>
[...subOrganizationsQuery.allKey(), "list", params] as const,
list: (params: TListSubOrganizationsDTO) =>
queryOptions({
queryKey: subOrganizationsQuery.listKey(params),
queryFn: async () => {
const { data } = await apiRequest.get<{ organizations: TSubOrganization[] }>(
"/api/v1/sub-organizations",
{
params: {
limit: params.limit,
offset: params.offset,
isAccessible: params.isAccessible
}
}
);
return data.organizations;
}
})
};
@@ -0,0 +1,23 @@
export type TSubOrganization = {
id: string;
name: string;
slug: string;
createdAt: string;
updatedAt: string;
parentOrgId: string;
};
export type TCreateSubOrganizationDTO = {
name: string;
};
export type TListSubOrganizationsDTO = {
limit?: number;
offset?: number;
isAccessible?: boolean;
};
export type TUpdateSubOrganizationDTO = {
subOrgId: string;
name: string;
};
@@ -13,6 +13,7 @@ export type SubscriptionPlan = {
customRateLimits: boolean;
pitRecovery: boolean;
githubOrgSync: boolean;
subOrganization?: boolean;
ipAllowlisting: boolean;
rbac: boolean;
secretVersioning: boolean;
@@ -47,6 +48,7 @@ export type SubscriptionPlan = {
gateway: boolean;
externalKms: boolean;
pkiEst: boolean;
pkiLegacyTemplates: boolean;
enforceMfa: boolean;
enforceGoogleSSO: boolean;
projectTemplates: boolean;
+1 -1
View File
@@ -42,7 +42,7 @@
--font-inter: "Inter", sans-serif;
--color-org-v1: #30B3FF;
--color-namespace-v1: #96ff59;
--max-width-8xl: 88rem; /* 1408px */
/* Primary */
--color-primary-50: #fffff5;
--color-primary-100: #fcfce8;
@@ -12,7 +12,7 @@ import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBan
import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
import { AdminSidebar } from "./Sidebar";
import { AdminNavBar } from "./AdminNavBar";
export const AdminLayout = () => {
const { t } = useTranslation();
@@ -33,9 +33,9 @@ export const AdminLayout = () => {
{!isLoading && !serverDetails?.emailConfigured && <SmtpBanner />}
{!isLoading && subscription.auditLogs && <AuditLogBanner />}
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<AdminSidebar />
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-4 pt-8 pb-4 dark:scheme-dark">
<div className="flex grow flex-col overflow-y-hidden">
<AdminNavBar />
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4 dark:scheme-dark">
<Outlet />
</div>
</div>
@@ -0,0 +1,99 @@
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faArrowLeft,
faBuilding,
faCog,
faDatabase,
faKey,
faLock,
faPlug,
faUserTie
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Tab, TabList, Tabs, Tooltip } from "@app/components/v2";
const generalTabs = [
{
label: "General",
icon: faCog,
link: "/admin/"
},
{
label: "Resource Overview",
icon: faBuilding,
link: "/admin/resources/overview"
},
{
label: "Access Control",
icon: faUserTie,
link: "/admin/access-management"
},
{
label: "Encryption",
icon: faLock,
link: "/admin/encryption"
},
{
label: "Authentication",
icon: faCheckCircle,
link: "/admin/authentication"
},
{
label: "Integrations",
icon: faPlug,
link: "/admin/integrations"
},
{
label: "Caching",
icon: faDatabase,
link: "/admin/caching"
},
{
label: "Environment Variables",
icon: faKey,
link: "/admin/environment"
}
];
export const AdminNavBar = () => {
const matchRoute = useMatchRoute();
return (
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
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">
<Tooltip position="bottom" content="Back to organization">
<Link to="/organization/projects">
<Tab value="back">
<FontAwesomeIcon icon={faArrowLeft} />
</Tab>
</Link>
</Tooltip>
{generalTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<Tab variant="instance" value={isActive ? "selected" : ""}>
{tab.label}
</Tab>
</Link>
);
})}
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
);
};
@@ -1,126 +0,0 @@
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faBuilding,
faChevronLeft,
faCog,
faDatabase,
faKey,
faLock,
faPlug,
faUserTie
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useMatchRoute } from "@tanstack/react-router";
import { Menu, MenuGroup, MenuItem } from "@app/components/v2";
const generalTabs = [
{
label: "General",
icon: faCog,
link: "/admin/"
},
{
label: "Encryption",
icon: faLock,
link: "/admin/encryption"
},
{
label: "Authentication",
icon: faCheckCircle,
link: "/admin/authentication"
},
{
label: "Integrations",
icon: faPlug,
link: "/admin/integrations"
},
{
label: "Caching",
icon: faDatabase,
link: "/admin/caching"
},
{
label: "Environment Variables",
icon: faKey,
link: "/admin/environment"
}
];
const othersTabs = [
{
label: "Access Controls",
icon: faUserTie,
link: "/admin/access-management"
},
{
label: "Resource Overview",
icon: faBuilding,
link: "/admin/resources/overview"
}
];
export const AdminSidebar = () => {
const matchRoute = useMatchRoute();
return (
<aside className="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:scheme-dark">
<div className="flex-1">
<Menu>
<MenuGroup title="Configuration">
{generalTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<MenuItem isSelected={Boolean(isActive)}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={tab.icon} />
</div>
{tab.label}
</div>
</MenuItem>
</Link>
);
})}
</MenuGroup>
<MenuGroup title="Others">
{othersTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<MenuItem isSelected={Boolean(isActive)}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={tab.icon} />
</div>
{tab.label}
</div>
</MenuItem>
</Link>
);
})}
</MenuGroup>
</Menu>
</div>
<Menu>
<Link to="/organization/projects">
<MenuItem
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<FontAwesomeIcon
className="mx-1 inline-block shrink-0"
icon={faChevronLeft}
flip="vertical"
/>
}
>
Back to Organization
</MenuItem>
</Link>
</Menu>
</nav>
</aside>
);
};
+65 -124
View File
@@ -1,9 +1,7 @@
import { faBook, faCog, faCube, faHome, faLock, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { Tab, TabList, Tabs } from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context";
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
@@ -12,138 +10,81 @@ export const KmsLayout = () => {
const { currentProject } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<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="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
className="px-4"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="unlock" />
KMS
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/kms/$projectId/overview"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCube} />
</div>
Overview
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/kms/$projectId/kmip"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faLock} />
</div>
KMIP
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/kms/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Project Access
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/kms/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/kms/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Project Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
variant="project"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/kms/$projectId/overview"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Overview</Tab>}
</Link>
</Menu>
</div>
<Link
to="/projects/kms/$projectId/kmip"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>KMIP</Tab>}
</Link>
<Link
to="/projects/kms/$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/kms/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/kms/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</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>
</div>
);
@@ -13,7 +13,7 @@ import { useFetchServerStatus } from "@app/hooks/api";
import { AuditLogBanner } from "./components/AuditLogBanner";
import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner";
import { Navbar } from "./components/NavBar";
import { OrgSidebar } from "./components/OrgSidebar";
import { OrgNavBar } from "./components/OrgNavBar";
import { RedisBanner } from "./components/RedisBanner";
import { SmtpBanner } from "./components/SmtpBanner";
@@ -41,16 +41,16 @@ export const OrganizationLayout = () => {
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
>
<Navbar />
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
{!isLoading && !serverDetails?.emailConfigured && <SmtpBanner />}
{!isLoading && subscription.auditLogs && <AuditLogBanner />}
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<OrgSidebar isHidden={isInsideProject} />
<div className="flex grow flex-col overflow-y-hidden">
<OrgNavBar isHidden={isInsideProject} />
{!isLoading && !isInsideProject && !serverDetails?.redisConfigured && <RedisBanner />}
{!isLoading && !isInsideProject && !serverDetails?.emailConfigured && <SmtpBanner />}
{!isLoading && !isInsideProject && subscription.auditLogs && <AuditLogBanner />}
{!window.isSecureContext && !isInsideProject && <InsecureConnectionBanner />}
<main
className={twMerge(
"flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-4 pt-8 pb-4 dark:scheme-dark",
isInsideProject && "p-0"
"flex-1 overflow-x-hidden bg-bunker-800 px-12 pt-10 pb-4 dark:scheme-dark",
isInsideProject ? "overflow-y-hidden p-0" : "overflow-y-auto"
)}
>
<Outlet />
@@ -6,11 +6,15 @@ import {
faBook,
faCaretDown,
faCheck,
faChevronRight,
faCubes,
faEnvelope,
faExclamationTriangle,
faGlobe,
faInfinity,
faInfo,
faInfoCircle,
faPlus,
faServer,
faSignOut,
faToolbox,
@@ -18,7 +22,7 @@ import {
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -33,6 +37,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
IconButton,
Modal,
ModalContent,
@@ -43,15 +50,22 @@ import { envConfig } from "@app/config/env";
import { useOrganization, useSubscription, useUser } from "@app/context";
import { isInfisicalCloud } from "@app/helpers/platform";
import { useToggle } from "@app/hooks";
import { projectKeys, useGetOrganizations, useLogoutUser } from "@app/hooks/api";
import {
projectKeys,
subOrganizationsQuery,
useGetOrganizations,
useGetOrgTrialUrl,
useLogoutUser
} from "@app/hooks/api";
import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { getAuthToken } from "@app/hooks/api/reactQuery";
import { SubscriptionPlan } from "@app/hooks/api/types";
import { Organization, SubscriptionPlan } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
import { NewSubOrganizationForm } from "./NewSubOrganizationForm";
import { NotificationDropdown } from "./NotificationDropdown";
const getPlan = (subscription: SubscriptionPlan) => {
@@ -118,10 +132,18 @@ export const INFISICAL_SUPPORT_OPTIONS = [
export const Navbar = () => {
const { user } = useUser();
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const [showAdminsModal, setShowAdminsModal] = useState(false);
const [showSubOrgForm, setShowSubOrgForm] = useState(false);
const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false);
const subOrgQuery = subOrganizationsQuery.list({ limit: 500, isAccessible: true });
const { data: subOrganizations = [] } = useQuery({
...subOrgQuery,
enabled: Boolean(subscription.subOrganization)
});
useEffect(() => {
if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) {
sessionStorage.setItem("paymentFailed", "true");
@@ -136,6 +158,7 @@ export const Navbar = () => {
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const router = useRouter();
const queryClient = useQueryClient();
const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false);
const location = useLocation();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
@@ -160,8 +183,11 @@ export const Navbar = () => {
}
await router.invalidate();
await navigateUserToOrg(navigate, orgId);
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
};
const { mutateAsync } = useGetOrgTrialUrl();
const logout = useLogoutUser();
const logOutUser = async () => {
try {
@@ -201,45 +227,85 @@ export const Navbar = () => {
const isServerAdminPanel = location.pathname.startsWith("/admin");
const isOrgScope = breadcrumbs?.length === 1; // TODO: scott/akhil is this adequate?
const isOrgScope = location.pathname.startsWith("/organization"); // TODO: scott/akhil is this adequate?
const handleOrgNav = async (org: Organization) => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
if (org.orgAuthMethod === AuthMethod.OIDC) {
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
} else {
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
}
window.close();
return;
}
if (org.googleSsoAuthEnforced) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
window.close();
return;
}
handleOrgChange(org?.id);
};
return (
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
<div>
<Link to="/organization/projects">
<img alt="infisical logo" src="/images/logotransparent.png" className="h-4" />
</Link>
</div>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
{isServerAdminPanel ? (
<>
<Link
to="/admin"
className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary"
>
<div>
<FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
</div>
<div className="whitespace-nowrap">Server Console</div>
<div className="z-10 flex min-h-12 items-center bg-mineshaft-900 px-4 pt-1">
<div className="mr-auto flex items-center overflow-hidden">
<div className="shrink-0">
<Link to="/organization/projects">
<img alt="infisical logo" src="/images/logotransparent.png" className="h-4" />
</Link>
<p className="pr-3 pl-3 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
// scott: remove /admin as we show server console above
<BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
) : null}
</>
) : (
<>
<div className="flex items-center">
<DropdownMenu modal={false}>
<Link to="/organization/projects">
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
</div>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
{isServerAdminPanel ? (
<>
<Link
to="/admin"
className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary"
>
<div>
<FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
</div>
<div className="whitespace-nowrap">Server Console</div>
</Link>
<p className="pr-3 pl-3 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
// scott: remove /admin as we show server console above
<BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
) : null}
</>
) : (
<>
<div className="flex 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
onClick={async () => {
navigate({
to: "/organization/projects",
search: (search) => ({ ...search, subOrganization: undefined })
});
if (isSubOrganization) {
await router.invalidate({ sync: true }).catch(() => null);
}
}}
variant="org"
className={twMerge("text-sm", !isOrgScope && "bg-transparent opacity-75")}
className={twMerge(
"max-w-full min-w-0 cursor-pointer text-sm",
(!isOrgScope || isSubOrganization) &&
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
)}
>
<FontAwesomeIcon icon={faGlobe} />
{currentOrg?.name}
<p className="truncate">{currentOrg?.name}</p>
</Badge>
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
{getPlan(subscription)}
@@ -258,87 +324,238 @@ export const Navbar = () => {
</Tooltip>
)}
</div>
</Link>
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="switch-org"
className="px-2 py-1"
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="switch-org"
className="px-2 py-1"
>
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
Organizations
</div>
{orgs?.map((org) => {
if (
subscription.subOrganization &&
(org.id === currentOrg?.id || org.id === currentOrg?.parentOrgId)
) {
return (
<DropdownSubMenu key={`${org.id}-sub-orgs`}>
<DropdownSubMenuTrigger
onClick={() => {
setIsOrgSelectOpen(false);
handleOrgNav(org);
}}
className="cursor-pointer font-normal"
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
)}
<p className="truncate">{org.name}</p>
<FontAwesomeIcon className="ml-auto shrink-0" icon={faChevronRight} />
</div>
</DropdownSubMenuTrigger>
<DropdownSubMenuContent
sideOffset={8}
alignOffset={-24}
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
Sub-Organizations
</div>
{subOrganizations.map((subOrg) => (
<DropdownMenuItem
onClick={async () => {
navigate({
to: "/organization/projects",
search: (prev) => ({ ...prev, subOrganization: subOrg.name })
});
await router.invalidate({ sync: true }).catch(() => null);
}}
className="cursor-pointer font-normal"
key={subOrg.id}
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === subOrg.id && (
<FontAwesomeIcon
icon={faCheck}
className="shrink-0 text-primary"
/>
)}
<p className="truncate">{subOrg.name}</p>
</div>
</DropdownMenuItem>
))}
{Boolean(subOrganizations.length) && (
<div className="mt-1 h-1 border-t border-mineshaft-600" />
)}
<DropdownMenuItem
className="cursor-pointer"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setShowSubOrgForm(true)}
>
New Sub-Organization
</DropdownMenuItem>
</DropdownSubMenuContent>
</DropdownSubMenu>
);
}
return (
<DropdownMenuItem
onClick={() => handleOrgNav(org)}
className="cursor-pointer font-normal"
key={org.id}
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
)}
<p className="truncate">{org.name}</p>
</div>
</DropdownMenuItem>
);
})}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faSignOut} />}
onClick={logOutUser}
>
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">organizations</div>
{orgs?.map((org) => {
return (
<DropdownMenuItem key={org.id}>
<Button
onClick={async () => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
if (org.orgAuthMethod === AuthMethod.OIDC) {
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
} else {
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
}
window.close();
return;
}
if (org.googleSsoAuthEnforced) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
window.close();
return;
}
handleOrgChange(org?.id);
}}
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{currentOrg.subOrganization && (
<>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
<DropdownMenu modal={false}>
<Link className="overflow-hidden" to="/organization/projects">
<Badge
variant="namespace"
className={twMerge(
"flex max-w-full min-w-0 cursor-pointer text-sm",
!isOrgScope &&
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
)}
>
<FontAwesomeIcon icon={faCubes} />
<p className="truncate">{currentOrg.subOrganization.name}</p>
</Badge>
</Link>
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
size="xs"
className="flex w-full items-center justify-start p-0 font-normal"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
ariaLabel="switch-org"
className="px-2 py-1"
>
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name}
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
Sub-Organizations
</div>
{subOrganizations.map((subOrg) => (
<DropdownMenuItem
onClick={async () => {
navigate({
to: "/organization/projects",
search: (prev) => ({ ...prev, subOrganization: subOrg.name })
});
await router.invalidate({ sync: true }).catch(() => null);
}}
className="cursor-pointer font-normal"
key={subOrg.id}
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === subOrg.id && (
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
)}
<p className="truncate">{subOrg.name}</p>
</div>
</Button>
</DropdownMenuItem>
))}
{Boolean(subOrganizations.length) && (
<div className="mt-1 h-1 border-t border-mineshaft-600" />
)}
<DropdownMenuItem
className="cursor-pointer"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setShowSubOrgForm(true)}
>
New Sub-Organization
</DropdownMenuItem>
);
})}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
</>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isOrgScope && (
<>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
<BreadcrumbContainer
className="min-w-[15rem] flex-1"
breadcrumbs={[breadcrumbs[0]] as TBreadcrumbFormat[]}
/>
) : null}
</>
)}
</>
)}
</div>
{subscription && subscription.slug === "starter" && !subscription.has_used_trial && (
<Tooltip content="Start Free Pro Trial">
<Button
variant="plain"
className="mr-2 border-mineshaft-500 px-2.5 py-1.5 whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
leftIcon={<FontAwesomeIcon icon={faInfinity} />}
onClick={async () => {
if (!subscription || !currentOrg) return;
// direct user to start pro trial
const url = await mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
window.location.href = url;
}}
>
Free Pro Trial
</Button>
</Tooltip>
)}
{user.superAdmin && !location.pathname.startsWith("/admin") && (
<Link
className="mr-2 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"
>
<FontAwesomeIcon icon={faServer} className="mr-2" />
Server Console
</Link>
)}
<div className="grow" />
<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">
@@ -466,6 +683,7 @@ export const Navbar = () => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Modal isOpen={showCardDeclinedModal} onOpenChange={setShowCardDeclinedModal}>
<ModalContent
title={
@@ -509,6 +727,20 @@ export const Navbar = () => {
</div>
</ModalContent>
</Modal>
<Modal isOpen={showSubOrgForm} onOpenChange={setShowSubOrgForm}>
<ModalContent
title="Create Sub-Organizations"
subTitle="Define a new sub-organization under your current organization."
>
<div className="mb-2">
<NewSubOrganizationForm
onClose={() => {
setShowSubOrgForm(false);
}}
/>
</div>
</ModalContent>
</Modal>
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
<ModalContent title="Server Administrators" subTitle="View all server administrators">
<div className="mb-2">
@@ -0,0 +1,94 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useCreateSubOrganization } from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
type ContentProps = {
onClose: () => void;
};
const AddOrgSchema = z.object({
name: slugSchema()
});
type FormData = z.infer<typeof AddOrgSchema>;
export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
const createSubOrg = useCreateSubOrganization();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm({
defaultValues: {
name: ""
},
resolver: zodResolver(AddOrgSchema)
});
const navigate = useNavigate();
const router = useRouter();
const onSubmit = async ({ name }: FormData) => {
try {
const { organization } = await createSubOrg.mutateAsync({
name
});
createNotification({
type: "success",
text: "Successfully created sub organization"
});
onClose();
navigate({
to: "/organization/projects",
search: (prev) => ({ ...prev, subOrganization: organization.name })
});
await router.invalidate({ sync: true }).catch(() => null);
} catch {
createNotification({
text: "Failed to create sub organization",
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
helperText="Must be slug-friendly"
errorText={error?.message}
label="Name"
>
<Input autoFocus value={value} onChange={onChange} placeholder="example-team" />
</FormControl>
)}
control={control}
name="name"
/>
<div className="flex w-full gap-4 pt-4">
<Button
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema="secondary"
>
Add Sub-Organization
</Button>
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</form>
);
};
@@ -16,15 +16,15 @@ export const Notification = ({ notification, onDelete }: Props) => {
return (
<div
className={twMerge(
"group relative flex cursor-pointer items-start border-b border-mineshaft-600 p-3 transition-colors",
"group relative flex cursor-pointer items-start border-b border-mineshaft-600 p-2 transition-colors",
notification.link ? "hover:bg-mineshaft-700" : "cursor-default",
!notification.isRead && "bg-mineshaft-800"
)}
>
<div className="flex w-full min-w-0 flex-col">
<div className="flex w-full min-w-0 flex-col p-1">
<div className="flex gap-2">
{!notification.isRead && (
<FontAwesomeIcon icon={faCircle} className="mt-1.5 size-2 text-yellow-400" />
<FontAwesomeIcon icon={faCircle} className="mt-0.5 size-2 text-yellow-400" />
)}
<Tooltip
content={<Markdown>{notification.title}</Markdown>}
@@ -45,7 +45,7 @@ export const Notification = ({ notification, onDelete }: Props) => {
</span>
)}
</div>
<div className="flex w-0 shrink-0 justify-end opacity-0 transition-all group-hover:w-[24px] group-hover:opacity-100">
<div className="mt-0.5 flex w-0 shrink-0 justify-end opacity-0 transition-all group-hover:w-[24px] group-hover:opacity-100">
<IconButton
ariaLabel="delete"
variant="plain"
@@ -0,0 +1,115 @@
import { Link, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
import { Tab, TabList, Tabs } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
type Props = {
isHidden?: boolean;
};
export const OrgNavBar = ({ isHidden }: Props) => {
const { isRootOrganization } = useOrganization();
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
const { pathname } = useLocation();
const variant = isRootOrganization ? "org" : "namespace";
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">
<motion.div
key="menu-org-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link to="/organization/projects">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
Overview
</Tab>
)}
</Link>
<Link to="/organization/app-connections">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
App Connections
</Tab>
)}
</Link>
<Link to="/organization/networking">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
Networking
</Tab>
)}
</Link>
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""} variant={variant}>
Secret Sharing
</Tab>
)}
</Link>
<Link to="/organization/access-management">
{({ isActive }) => (
<Tab
variant={variant}
value={
isActive ||
pathname.match(
/organization\/members|organization\/identities|organization\/groups|organization\/roles/
)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link to="/organization/audit-logs">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
Audit Logs
</Tab>
)}
</Link>
{isRootOrganization && (
<Link to="/organization/billing">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
Usage & Billing
</Tab>
)}
</Link>
)}
<Link to="/organization/settings">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
Settings
</Tab>
)}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
)}
<CreateOrgModal
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}
/>
</>
);
};
@@ -0,0 +1 @@
export { OrgNavBar } from "./OrgNavBar";
@@ -1,214 +0,0 @@
import {
faBook,
faCog,
faInfinity,
faMoneyBill,
faNetworkWired,
faPlug,
faShare,
faTable,
faUsers,
faUserTie
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
import { Menu, MenuGroup, MenuItem, Tooltip } from "@app/components/v2";
import { useOrganization, useSubscription, useUser } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetOrgTrialUrl } from "@app/hooks/api";
type Props = {
isHidden?: boolean;
};
export const OrgSidebar = ({ isHidden }: Props) => {
const { subscription } = useSubscription();
const { user } = useUser();
const { mutateAsync } = useGetOrgTrialUrl();
const { currentOrg } = useOrganization();
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
return (
<>
<AnimatePresence mode="popLayout">
{!isHidden && (
<motion.aside
key="org-sidebar"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: -240 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -240 }}
layout
className="dark z-10 w-60 border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-800 to-mineshaft-900"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<Menu>
<MenuGroup title="Overview">
<Link to="/organization/projects">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faTable} />
</div>
Overview
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/access-management">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Organization Access
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/billing">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faMoneyBill} className="mr-4" />
</div>
Usage & Billing
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/audit-logs">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} className="mr-4" />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/settings">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} className="mr-4" />
</div>
Organization Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Resources">
<Link to="/organization/app-connections">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faPlug} className="mr-4" />
</div>
App Connections
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/networking">
{({ isActive }) => (
<MenuItem variant="org" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faNetworkWired} className="mr-4" />
</div>
Networking
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
<div className="grow" />
<Menu>
{subscription &&
subscription.slug === "starter" &&
!subscription.has_used_trial && (
<Tooltip content="Start Free Pro Trial">
<MenuItem
variant="org"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<FontAwesomeIcon
className="mx-1 inline-block shrink-0"
icon={faInfinity}
/>
}
onClick={async () => {
if (!subscription || !currentOrg) return;
// direct user to start pro trial
const url = await mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
window.location.href = url;
}}
>
Pro Trial
</MenuItem>
</Tooltip>
)}
<Link to="/organization/secret-sharing">
<MenuItem
variant="org"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faShare} />
</div>
}
>
Share Secret
</MenuItem>
</Link>
{user.superAdmin && (
<Link to="/admin">
<MenuItem
variant="org"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon
className="mx-1 inline-block shrink-0"
icon={faUserTie}
/>
</div>
}
>
Server Console
</MenuItem>
</Link>
)}
</Menu>
</nav>
</motion.aside>
)}
</AnimatePresence>
<CreateOrgModal
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}
/>
</>
);
};
@@ -1 +0,0 @@
export { OrgSidebar } from "./OrgSidebar";
+72 -149
View File
@@ -1,19 +1,9 @@
import { useEffect } from "react";
import {
faBook,
faBoxOpen,
faCog,
faDisplay,
faHome,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { Tab, TabList, Tabs } from "@app/components/v2";
import { useProject, useProjectPermission, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
@@ -23,7 +13,7 @@ export const PamLayout = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]);
useEffect(() => {
@@ -35,152 +25,85 @@ export const PamLayout = () => {
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<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="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
className="px-4"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="groups" />
PAM
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/pam/$projectId/accounts"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUser} />
</div>
Accounts
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/resources"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBoxOpen} />
</div>
Resources
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/sessions"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faDisplay} />
</div>
Sessions
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/pam/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Project Access
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Project Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/pam/$projectId/accounts"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Accounts</Tab>}
</Link>
</Menu>
</div>
<Link
to="/projects/pam/$projectId/resources"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Resources</Tab>}
</Link>
<Link
to="/projects/pam/$projectId/sessions"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Sessions</Tab>}
</Link>
<Link
to="/projects/pam/$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/pam/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/pam/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</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>
</div>
<UpgradePlanModal
@@ -1,9 +1,7 @@
import { useTranslation } from "react-i18next";
import { faArrowLeft, faMobile } from "@fortawesome/free-solid-svg-icons";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { WishForm } from "@app/components/features/WishForm";
import { Outlet } from "@tanstack/react-router";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
@@ -12,27 +10,10 @@ export const PersonalSettingsLayout = () => {
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
<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">
<aside className="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:scheme-dark">
<div className="grow">
<Link to="/organization/projects">
<div className="my-6 flex cursor-default items-center justify-center pr-2 text-sm text-mineshaft-300 hover:text-mineshaft-100">
<FontAwesomeIcon icon={faArrowLeft} className="pr-3" />
Back to organization
</div>
</Link>
</div>
<div className="relative mt-10 flex w-full cursor-default flex-col items-center px-3 text-sm text-mineshaft-400">
{(window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com")) && <WishForm />}
</div>
)
</nav>
</aside>
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 dark:scheme-dark">
<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>
@@ -1,249 +1,188 @@
import { useTranslation } from "react-i18next";
import {
faBell,
faBook,
faCertificate,
faCog,
faFileLines,
faHome,
faMobile,
faPlug,
faPuzzlePiece,
faSitemap,
faStamp,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context";
import { Tab, TabList, Tabs } from "@app/components/v2";
import { useProject, useProjectPermission, useSubscription } from "@app/context";
import {
useListWorkspaceCertificateTemplates,
useListWorkspacePkiSubscribers
} from "@app/hooks/api";
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
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({
projectId: currentProject?.id || ""
});
const templates = templatesData?.certificateTemplates || [];
const hasExistingSubscribers = subscribers.length > 0;
const hasExistingTemplates = templates.length > 0;
const showLegacySection =
subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates;
const location = useLocation();
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<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="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
className="px-4"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="note" />
PKI Manager
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faSitemap} />
</div>
Subscribers
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-templates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faFileLines} />
</div>
Certificate Templates
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/certificates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCertificate} />
</div>
Certificates
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faStamp} />
</div>
Certificates Authority
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/alerting"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBell} />
</div>
Alerting
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faPuzzlePiece} />
</div>
Integrations
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faPlug} />
</div>
App Connections
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/cert-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Project Access
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Project Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
<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" : ""}>Policies</Tab>}
</Link>
</Menu>
</div>
<Link
to="/projects/cert-management/$projectId/certificates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/pki-collections\//)
? "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>
)}
</>
)}
<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 className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</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>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
@@ -15,7 +15,7 @@ export const AssumePrivilegeModeBanner = () => {
if (!assumedPrivilegeDetails) return null;
return (
<div className="z-10 -mx-4 flex items-center justify-center gap-2 rounded-sm border border-mineshaft-600 bg-primary-400 p-2 text-mineshaft-800 shadow-sm">
<div className="flex w-full items-center border-b border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
<div>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
You are currently viewing the project with privileges of{" "}
@@ -24,7 +24,7 @@ export const AssumePrivilegeModeBanner = () => {
{assumedPrivilegeDetails?.actorName}
</b>
</div>
<div>
<div className="ml-auto">
<Button
size="xs"
variant="outline_bg"
@@ -35,10 +35,19 @@ import {
import { getProjectHomePage } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import { useGetUserProjects } from "@app/hooks/api";
import { Project } from "@app/hooks/api/projects/types";
import { Project, ProjectType } from "@app/hooks/api/projects/types";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
const PROJECT_TYPE_NAME: Record<ProjectType, string> = {
[ProjectType.SecretManager]: "Secrets Management",
[ProjectType.CertificateManager]: "PKI",
[ProjectType.SSH]: "SSH",
[ProjectType.KMS]: "KMS",
[ProjectType.PAM]: "PAM",
[ProjectType.SecretScanning]: "Secret Scanning"
};
export const ProjectSelect = () => {
const [searchProject, setSearchProject] = useState("");
const { currentProject: currentWorkspace } = useProject();
@@ -99,26 +108,24 @@ export const ProjectSelect = () => {
}, [projects, projectFavorites, currentWorkspace]);
return (
<div className="-mr-2 flex w-full items-center gap-1">
<div className="mr-2 flex items-center gap-1 overflow-hidden">
<DropdownMenu modal={false}>
<Link
to={getProjectHomePage(currentWorkspace.type, currentWorkspace.environments)}
params={{
projectId: currentWorkspace.id
}}
className="group flex cursor-pointer items-center gap-x-1.5 overflow-hidden hover:text-white"
>
<div className="relative flex cursor-pointer items-center gap-2 text-sm text-white duration-100 hover:text-primary">
<Tooltip content={currentWorkspace.name} className="max-w-96 break-words">
<Badge
variant="project"
className="max-w-44 overflow-hidden text-sm text-ellipsis whitespace-nowrap"
>
<FontAwesomeIcon icon={faCube} />
{currentWorkspace?.name}
</Badge>
</Tooltip>
</div>
<p className="inline-block truncate text-mineshaft-200 group-hover:underline">
{currentWorkspace?.name}
</p>
<Badge variant="project" className="cursor-pointer">
<FontAwesomeIcon icon={faCube} />
<span>
{currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"}
</span>
</Badge>
</Link>
<DropdownMenuTrigger asChild>
<div>
@@ -164,9 +171,21 @@ export const ProjectSelect = () => {
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
},
search: {
subOrganization: currentOrg?.subOrganization?.name
}
});
window.location.assign(url.to.replaceAll("$projectId", workspace.id));
const urlInstance = new URL(
`${window.location.origin}/${url.to.replaceAll("$projectId", workspace.id)}`
);
if (currentOrg?.subOrganization) {
urlInstance.searchParams.set(
"subOrganization",
currentOrg.subOrganization.name
);
}
window.location.assign(urlInstance);
}}
icon={
currentWorkspace?.id === workspace.id && (
@@ -1,21 +1,10 @@
import { useTranslation } from "react-i18next";
import {
faArrowsSpin,
faBook,
faCheckToSlot,
faCog,
faHome,
faMobile,
faPlug,
faPuzzlePiece,
faUsers,
faVault
} from "@fortawesome/free-solid-svg-icons";
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";
import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { Badge, Tab, TabList, Tabs } from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context";
import {
useGetAccessRequestsCount,
@@ -28,10 +17,10 @@ import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePri
export const SecretManagerLayout = () => {
const { currentProject, projectId } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
const { t } = useTranslation();
const projectSlug = currentProject?.slug || "";
const location = useLocation();
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
projectId
@@ -54,208 +43,131 @@ export const SecretManagerLayout = () => {
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<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="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
className="px-4"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="vault" />
Secrets Manager
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentProject.id,
...(currentProject.environments.length
? { envSlug: currentProject.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<MenuItem
variant="project"
isSelected={
isActive ||
location.pathname.startsWith(
`/projects/secret-management/${currentProject.id}/overview`
)
}
>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faVault} />
</div>
Secrets
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faPuzzlePiece} />
</div>
Integrations
</div>
</MenuItem>
)}
</Link>
{Boolean(secretRotations?.length) && (
<Link
to="/projects/secret-management/$projectId/secret-rotation"
params={{
projectId: currentProject.id
}}
<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 }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faArrowsSpin} />
</div>
Secret Rotations
</div>
</MenuItem>
)}
</Link>
Overview
</Tab>
)}
<Link
to="/projects/secret-management/$projectId/approval"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCheckToSlot} />
</div>
Approvals
{Boolean(
secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="primary" className="ml-1.5">
{pendingRequestsCount}
</Badge>
)}
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faPlug} />
</div>
App Connections
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/secret-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Project Access
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Project Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
variant="project"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
</Link>
</Menu>
</div>
<Link
to="/projects/secret-management/$projectId/approval"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Approvals
{Boolean(
secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="primary" 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
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Secret Rotations</Tab>
)}
</Link>
)}
<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 className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</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>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
@@ -1,17 +1,7 @@
import {
faBook,
faCog,
faDatabase,
faHome,
faMagnifyingGlass,
faPlug,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { Badge, Tab, TabList, Tabs } from "@app/components/v2";
import {
ProjectPermissionSub,
useProject,
@@ -29,6 +19,7 @@ export const SecretScanningLayout = () => {
const { permission } = useProjectPermission();
const { subscription } = useSubscription();
const location = useLocation();
const { data: unresolvedFindings } = useGetSecretScanningUnresolvedFindingCount(
currentProject.id,
@@ -45,158 +36,94 @@ export const SecretScanningLayout = () => {
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<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="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
className="px-4"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="secret-scan" />
Secret Scanning
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/secret-scanning/$projectId/data-sources"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faDatabase} />
</div>
Data Sources
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-scanning/$projectId/findings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex w-full gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faMagnifyingGlass} />
</div>
<span>Findings</span>
{Boolean(unresolvedFindings) && (
<Badge variant="primary" className="mr-2 ml-auto h-min">
{unresolvedFindings}
</Badge>
)}
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-scanning/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faPlug} />
</div>
App Connections
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/secret-scanning/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Project Access
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-scanning/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/secret-scanning/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Project Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
variant="project"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/secret-scanning/$projectId/data-sources"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Data Sources</Tab>}
</Link>
</Menu>
</div>
<Link
to="/projects/secret-scanning/$projectId/findings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Findings
{Boolean(unresolvedFindings) && (
<Badge variant="primary" className="ml-2 h-min">
{unresolvedFindings}
</Badge>
)}
</Tab>
)}
</Link>
<Link
to="/projects/secret-scanning/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>App Connections</Tab>}
</Link>
<Link
to="/projects/secret-scanning/$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-scanning/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/secret-scanning/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</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>
</div>
);
+87 -141
View File
@@ -1,17 +1,8 @@
import {
faBook,
faCog,
faHome,
faServer,
faStamp,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { Tab, TabList, Tabs } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
@@ -24,149 +15,104 @@ import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePri
export const SshLayout = () => {
const { currentProject } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<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="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
className="px-4"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="terminal" />
SSH
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/ssh/$projectId/overview"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faServer} />
</div>
Hosts
</div>
</MenuItem>
)}
</Link>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) =>
isAllowed && (
<Link
to="/projects/ssh/$projectId/cas"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faStamp} />
</div>
Certificates Authority
</div>
</MenuItem>
)}
</Link>
)
}
</ProjectPermissionCan>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/ssh/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Project Access
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/ssh/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/ssh/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem variant="project" isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Project Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
variant="project"
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/ssh/$projectId/overview"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/ssh-host-groups\//) ? "selected" : ""
}
>
Hosts
</Tab>
)}
</Link>
</Menu>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) =>
isAllowed && (
<Link
to="/projects/ssh/$projectId/cas"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={isActive || location.pathname.match(/\/ca\//) ? "selected" : ""}
>
Certificate Authorities
</Tab>
)}
</Link>
)
}
</ProjectPermissionCan>
<Link
to="/projects/ssh/$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/ssh/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/ssh/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</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>
</div>
);
@@ -13,8 +13,8 @@ export const AccessManagementPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Access Control" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Access Control"
@@ -13,8 +13,8 @@ export const AuthenticationPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Authentication"
@@ -13,8 +13,8 @@ export const CachingPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Caching"
@@ -13,8 +13,8 @@ export const EncryptionPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Encryption"
@@ -13,8 +13,8 @@ export const EnvironmentPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Environment Variables"
@@ -15,8 +15,8 @@ export const GeneralPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="General"
@@ -13,8 +13,8 @@ export const IntegrationsPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Integrations"
@@ -13,18 +13,24 @@ export const ResourceOverviewPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Resource Overview" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Resource Overview"
description="Manage resources within your Infisical instance."
/>
<Tabs defaultValue="tab-organizations">
<Tabs orientation="vertical" defaultValue="tab-organizations">
<TabList>
<Tab value="tab-organizations">Organizations</Tab>
<Tab value="tab-users">Users</Tab>
<Tab value="tab-identities">Identities</Tab>
<Tab variant="instance" value="tab-organizations">
Organizations
</Tab>
<Tab variant="instance" value="tab-users">
Users
</Tab>
<Tab variant="instance" value="tab-identities">
Identities
</Tab>
</TabList>
<TabPanel value="tab-organizations">
<OrganizationsTable />
@@ -4,19 +4,20 @@ import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiAlertsSection } from "./components";
export const AlertingPage = () => {
const { t } = useTranslation();
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Alerting" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Alerting"
description="Configure alerts for expiring certificates and CAs to maintain security and compliance."
/>
@@ -1,5 +1,7 @@
import { Helmet } from "react-helmet";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -18,6 +20,7 @@ import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { CaType, useDeleteCa, useGetCa } from "@app/hooks/api";
import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types";
import { ProjectType } from "@app/hooks/api/projects/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaInstallCertModal } from "../CertificateAuthoritiesPage/components/CaInstallCertModal";
@@ -84,10 +87,24 @@ const Page = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={data.name}>
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Certificate Authorities
</Link>
<PageHeader
scope={ProjectType.CertificateManager}
description="Manage certificate authority"
title={data.name}
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { ExternalCaSection } from "./components/ExternalCaSection";
import { CaSection } from "./components";
@@ -11,13 +12,13 @@ import { CaSection } from "./components";
export const CertificateAuthoritiesPage = () => {
const { t } = useTranslation();
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Authorities" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Certificate Authorities"
description="Manage certificate authorities for issuing and signing certificates"
/>
@@ -12,8 +12,7 @@ import {
Modal,
ModalContent,
Select,
SelectItem,
Switch
SelectItem
// DatePicker
} from "@app/components/v2";
import { useProject } from "@app/context";
@@ -60,12 +59,7 @@ const schema = z
commonName: z.string(),
notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
maxPathLength: z.string(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
])
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
})
.required()
})
@@ -145,7 +139,11 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
maxPathLength: ca.configuration.maxPathLength
? String(ca.configuration.maxPathLength)
: "",
keyAlgorithm: ca.configuration.keyAlgorithm
keyAlgorithm: Object.values(CertKeyAlgorithm).includes(
ca.configuration.keyAlgorithm as CertKeyAlgorithm
)
? ca.configuration.keyAlgorithm
: CertKeyAlgorithm.RSA_2048
}
});
} else {
@@ -153,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
type: CaType.INTERNAL,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
enableDirectIssuance: false,
configuration: {
type: InternalCaType.ROOT,
organization: "",
@@ -457,23 +455,6 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="enableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="enable-direct-issuance"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Enable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center">
<Button
className="mr-4"
@@ -9,6 +9,7 @@ import {
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiCollectionSection } from "../AlertingPage/components";
import { CertificatesSection } from "./components";
@@ -27,13 +28,13 @@ export const CertificatesPage = () => {
);
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Certificates"
description="View and track issued certificates, monitor expiration dates, and manage certificate lifecycles."
/>
@@ -0,0 +1,96 @@
import { Control, Controller } from "react-hook-form";
import { FormControl, Select, SelectItem } from "@app/components/v2";
type AlgorithmOption = {
value: string;
label: string;
};
type AlgorithmSelectorsProps = {
control: Control<any>;
availableSignatureAlgorithms: AlgorithmOption[];
availableKeyAlgorithms: AlgorithmOption[];
signatureError?: string;
keyError?: string;
};
export const AlgorithmSelectors = ({
control,
availableSignatureAlgorithms,
availableKeyAlgorithms,
signatureError,
keyError
}: AlgorithmSelectorsProps) => {
return (
<div className="grid grid-cols-2 gap-4">
<div>
<Controller
control={control}
name="signatureAlgorithm"
render={({ field: { onChange, ...field } }) => (
<FormControl
label="Signature Algorithm"
errorText={signatureError}
isError={Boolean(signatureError)}
isRequired
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder={
availableSignatureAlgorithms.length > 0
? "Select signature algorithm"
: "No algorithms available"
}
position="popper"
>
{availableSignatureAlgorithms.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="keyAlgorithm"
render={({ field: { onChange, ...field } }) => (
<FormControl
label="Key Algorithm"
errorText={keyError}
isError={Boolean(keyError)}
isRequired
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder={
availableKeyAlgorithms.length > 0
? "Select key algorithm"
: "No algorithms available"
}
position="popper"
>
{availableKeyAlgorithms.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
);
};
@@ -7,7 +7,6 @@ import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
@@ -24,8 +23,6 @@ const schema = z.object({
certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"),
chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"),
friendlyName: z.string(),
collectionId: z.string().optional()
});
@@ -72,7 +69,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
collectionId
}: FormData) => {
try {
@@ -84,8 +80,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
pkiCollectionId: collectionId
});
@@ -150,20 +144,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -0,0 +1,526 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useGetCert } from "@app/hooks/api";
import { useCreateCertificateV3 } from "@app/hooks/api/ca";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
import { AlgorithmSelectors } from "./AlgorithmSelectors";
import { CertificateContent } from "./CertificateContent";
import { filterUsages, formatSubjectAltNames, getAttributeValue } from "./certificateUtils";
import { KeyUsageSection } from "./KeyUsageSection";
import { SubjectAltNamesField } from "./SubjectAltNamesField";
import { useCertificateTemplate } from "./useCertificateTemplate";
const createSchema = (shouldShowSubjectSection: boolean) => {
return z.object({
profileId: z.string().min(1, "Profile is required"),
subjectAttributes: shouldShowSubjectSection
? z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.min(1, "At least one subject attribute is required")
: z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.optional(),
subjectAltNames: z
.array(
z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z.string().min(1, "Value is required")
})
)
.default([]),
ttl: z.string().trim().min(1, "TTL is required"),
signatureAlgorithm: z.string().min(1, "Signature algorithm is required"),
keyAlgorithm: z.string().min(1, "Key algorithm is required"),
keyUsages: z
.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
})
.default({}),
extendedKeyUsages: z
.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
.default({})
});
};
export type FormData = z.infer<ReturnType<typeof createSchema>>;
type Props = {
popUp: UsePopUpState<["certificateIssuance"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["certificateIssuance"]>,
state?: boolean
) => void;
profileId?: string;
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
};
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const { currentProject } = useProject();
const inputSerialNumber =
(popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "";
const sanitizedSerialNumber = inputSerialNumber.replace(/[^a-fA-F0-9:]/g, "");
const { data: cert } = useGetCert(sanitizedSerialNumber);
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "",
includeMetrics: false,
enrollmentType: "api"
});
const { mutateAsync: createCertificate } = useCreateCertificateV3();
const selectedProfileId = useMemo(() => {
const form = document.querySelector('select[name="profileId"]') as HTMLSelectElement;
return form?.value || profileId || "";
}, [profileId]);
const selectedProfile = useMemo(
() => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId),
[profilesData?.certificateProfiles, selectedProfileId]
);
const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: selectedProfile?.certificateTemplateId || ""
});
const {
control,
handleSubmit,
reset,
watch,
setValue,
formState,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(createSchema((templateData?.subject?.length || 0) > 0)),
defaultValues: {
profileId: profileId || "",
subjectAttributes: [],
subjectAltNames: [],
ttl: "30d",
signatureAlgorithm: "",
keyAlgorithm: "",
keyUsages: {},
extendedKeyUsages: {}
}
});
const actualSelectedProfileId = watch("profileId");
const actualSelectedProfile = useMemo(
() => profilesData?.certificateProfiles?.find((p) => p.id === actualSelectedProfileId),
[profilesData?.certificateProfiles, actualSelectedProfileId]
);
const {
constraints,
filteredKeyUsages,
filteredExtendedKeyUsages,
availableSignatureAlgorithms,
availableKeyAlgorithms,
resetConstraints
} = useCertificateTemplate(
templateData,
actualSelectedProfile,
popUp?.certificateIssuance?.isOpen || false,
setValue,
watch
);
const resetAllState = useCallback(() => {
setCertificateDetails(null);
resetConstraints();
reset();
}, [reset, resetConstraints]);
useEffect(() => {
if (cert) {
const subjectAttrs: Array<{ type: "common_name"; value: string }> = [];
if (cert.commonName)
subjectAttrs.push({ type: "common_name" as const, value: cert.commonName });
reset({
profileId: "",
subjectAttributes:
subjectAttrs.length > 0 ? subjectAttrs : [{ type: "common_name" as const, value: "" }],
subjectAltNames: cert.subjectAltNames
? cert.subjectAltNames.split(",").map((name) => {
const trimmed = name.trim();
if (trimmed.includes("@"))
return { type: CertSubjectAlternativeNameType.EMAIL, value: trimmed };
if (trimmed.match(/^\d+\.\d+\.\d+\.\d+$/))
return { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: trimmed };
if (trimmed.startsWith("http"))
return { type: CertSubjectAlternativeNameType.URI, value: trimmed };
return { type: CertSubjectAlternativeNameType.DNS_NAME, value: trimmed };
})
: [],
ttl: "",
signatureAlgorithm: "",
keyAlgorithm: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(cert.extendedKeyUsages || []).map((name) => [name, true])
)
});
}
}, [cert, reset]);
useEffect(() => {
if (popUp?.certificateIssuance?.isOpen && profileId && !cert) {
setValue("profileId", profileId);
}
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
const onFormSubmit = useCallback(
async ({
profileId: formProfileId,
subjectAttributes,
subjectAltNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Project not found. Please refresh and try again.",
type: "error"
});
return;
}
if (!formProfileId) {
createNotification({
text: "Please select a certificate profile.",
type: "error"
});
return;
}
let commonName = "";
if (
constraints.shouldShowSubjectSection &&
subjectAttributes &&
subjectAttributes.length > 0
) {
commonName = getAttributeValue(subjectAttributes, "common_name");
if (!commonName.trim()) {
createNotification({
text: "Common name is required.",
type: "error"
});
return;
}
}
const certificateRequest: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
if (constraints.shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) {
certificateRequest.altNames = formattedSans;
}
}
const { serialNumber, certificate, certificateChain, privateKey } =
await createCertificate(certificateRequest);
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
} catch (err) {
console.error("Certificate creation failed:", err);
const errorMessage =
err instanceof Error
? err.message
: "An unexpected error occurred while creating the certificate";
createNotification({
text: `Failed to create certificate: ${errorMessage}`,
type: "error"
});
}
},
[
currentProject?.slug,
createCertificate,
constraints.shouldShowSubjectSection,
constraints.shouldShowSanSection
]
);
const getModalTitle = () => {
if (certificateDetails) return "Certificate Created Successfully";
if (cert) return "Certificate Details";
return "Issue New Certificate";
};
const getModalSubTitle = () => {
if (certificateDetails) return "Certificate has been successfully created and is ready for use";
if (cert) return "View certificate information";
return "Issue a new certificate using a certificate profile";
};
return (
<Modal
isOpen={popUp?.certificateIssuance?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateIssuance", isOpen);
if (!isOpen) {
resetAllState();
}
}}
>
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
{certificateDetails && (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
/>
)}
{cert && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-mineshaft-300">Certificate Details</h4>
<p className="text-sm text-mineshaft-400">Serial Number: {cert.serialNumber}</p>
<p className="text-sm text-mineshaft-400">Common Name: {cert.commonName}</p>
<p className="text-sm text-mineshaft-400">Status: {cert.status}</p>
</div>
</div>
)}
{!cert && !certificateDetails && (
<form onSubmit={handleSubmit(onFormSubmit)}>
{!profileId && (
<Controller
control={control}
name="profileId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={
<div>
<FormLabel
isRequired
label="Certificate Profile"
icon={
<Tooltip
className="text-center"
content={
<span>
Certificate profiles define the policies and enrollment methods
for certificate issuance. The selected profile will enforce
validation rules and determine the CA used for signing.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
/>
</div>
}
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder="Select a certificate profile"
position="popper"
>
{profilesData?.certificateProfiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
{(actualSelectedProfile || profileId) && (
<>
{constraints.shouldShowSubjectSection && (
<Controller
control={control}
name="subjectAttributes"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Common Name"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Input
value={value?.[0]?.value || ""}
onChange={(e) => {
onChange([{ type: "common_name", value: e.target.value }]);
}}
placeholder="example.com"
/>
</FormControl>
)}
/>
)}
{constraints.shouldShowSanSection && (
<SubjectAltNamesField
control={control}
allowedSanTypes={constraints.allowedSanTypes}
error={formState.errors.subjectAltNames?.message}
/>
)}
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Time to Live (TTL)"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="30d, 1y, 8760h" />
</FormControl>
)}
/>
<AlgorithmSelectors
control={control}
availableSignatureAlgorithms={availableSignatureAlgorithms}
availableKeyAlgorithms={availableKeyAlgorithms}
signatureError={formState.errors.signatureAlgorithm?.message}
keyError={formState.errors.keyAlgorithm?.message}
/>
<Accordion type="single" collapsible className="w-full">
<KeyUsageSection
control={control}
title="Key Usages"
accordionValue="key-usages"
namePrefix="keyUsages"
options={filteredKeyUsages}
requiredUsages={constraints.requiredKeyUsages}
/>
<KeyUsageSection
control={control}
title="Extended Key Usages"
accordionValue="extended-key-usages"
namePrefix="extendedKeyUsages"
options={filteredExtendedKeyUsages}
requiredUsages={constraints.requiredExtendedKeyUsages}
/>
</Accordion>
</>
)}
<div className="mt-7 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)}
>
{cert ? "Update" : "Issue Certificate"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => {
handlePopUpToggle("certificateIssuance", false);
}}
>
Cancel
</Button>
</div>
</form>
)}
</ModalContent>
</Modal>
);
};
@@ -46,9 +46,8 @@ const schema = z.object({
certificateTemplateId: z.string().optional(),
caId: z.string(),
collectionId: z.string().optional(),
friendlyName: z.string(),
commonName: z.string().trim().min(1),
altNames: z.string(),
subjectAltNames: z.string(),
ttl: z.string().trim(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
@@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
if (cert) {
reset({
caId: cert.caId,
friendlyName: cert.friendlyName,
commonName: cert.commonName,
altNames: cert.altNames,
subjectAltNames: cert.subjectAltNames,
certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
ttl: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
@@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} else {
reset({
caId: "",
friendlyName: "",
commonName: "",
altNames: "",
subjectAltNames: "",
ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
keyUsages: {
@@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const onFormSubmit = async ({
caId,
friendlyName,
collectionId,
commonName,
altNames,
subjectAltNames,
ttl,
keyUsages,
extendedKeyUsages
@@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
projectSlug: currentProject.slug,
pkiCollectionId: collectionId,
friendlyName,
commonName,
altNames,
subjectAltNames,
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
@@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
/>
</>
)}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -391,7 +372,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="altNames"
name="subjectAltNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Alternative Names (SANs)"
@@ -7,22 +7,28 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProject
useProject,
useSubscription
} from "@app/context";
import { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { mutateAsync: deleteCert } = useDeleteCert();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificateIssuance",
"certificate",
"certificateImport",
"certificateCert",
@@ -73,7 +79,9 @@ export const CertificatesSection = () => {
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
onClick={() =>
handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance")
}
isDisabled={!isAllowed}
>
Issue
@@ -83,7 +91,11 @@ export const CertificatesSection = () => {
</ProjectPermissionCan>
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
{isLegacyTemplatesEnabled ? (
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
) : (
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
)}
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
@@ -0,0 +1,78 @@
import { Control, Controller } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
Checkbox,
FormLabel
} from "@app/components/v2";
type KeyUsageOption = {
label: string;
value: string;
};
type KeyUsageSectionProps = {
control: Control<any>;
title: string;
accordionValue: string;
namePrefix: "keyUsages" | "extendedKeyUsages";
options: KeyUsageOption[];
requiredUsages: string[];
};
export const KeyUsageSection = ({
control,
title,
accordionValue,
namePrefix,
options,
requiredUsages
}: KeyUsageSectionProps) => {
if (options.length === 0) return null;
return (
<AccordionItem value={accordionValue}>
<AccordionTrigger>{title}</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{options.map(({ label, value }) => {
const isRequired = requiredUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`${namePrefix}.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`${namePrefix}-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
<div className="flex items-center gap-2">
<FormLabel
id={`${namePrefix}-${value}`}
className={`text-sm ${
isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"
}`}
label={label}
/>
{isRequired && <span className="text-xs">(Required)</span>}
</div>
</div>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
);
};
@@ -0,0 +1,99 @@
import { Control, Controller } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
import { getSanPlaceholder, getSanTypeLabels, SubjectAltName } from "./certificateUtils";
type SubjectAltNamesFieldProps = {
control: Control<any>;
allowedSanTypes: CertSubjectAlternativeNameType[];
error?: string;
};
export const SubjectAltNamesField = ({
control,
allowedSanTypes,
error
}: SubjectAltNamesFieldProps) => {
const sanTypeLabels = getSanTypeLabels();
return (
<Controller
control={control}
name="subjectAltNames"
render={({ field: { onChange, value } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
errorText={error}
isError={Boolean(error)}
>
<div className="space-y-2">
{value.map((san: SubjectAltName, index: number) => (
// eslint-disable-next-line react/no-array-index-key
<div key={`subject-alt-name-${index}`} className="flex items-center gap-2">
<Select
value={san.type}
onValueChange={(newType) => {
const newValue = [...value];
newValue[index] = {
...san,
type: newType as CertSubjectAlternativeNameType
};
onChange(newValue);
}}
className="w-24"
>
{allowedSanTypes.map((sanType) => (
<SelectItem key={sanType} value={sanType}>
{sanTypeLabels[sanType]}
</SelectItem>
))}
</Select>
<Input
value={san.value}
onChange={(e) => {
const newValue = [...value];
newValue[index] = { ...san, value: e.target.value };
onChange(newValue);
}}
placeholder={getSanPlaceholder(san.type)}
className="flex-1"
/>
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => {
const newValue = value.filter((_: any, i: number) => i !== index);
onChange(newValue);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<Button
type="button"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
const defaultType =
allowedSanTypes.length > 0
? allowedSanTypes[0]
: CertSubjectAlternativeNameType.DNS_NAME;
onChange([...value, { type: defaultType, value: "" }]);
}}
className="w-full"
>
Add SAN
</Button>
</div>
</FormControl>
)}
/>
);
};
@@ -0,0 +1,51 @@
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
export const getSanPlaceholder = (sanType: CertSubjectAlternativeNameType): string => {
switch (sanType) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "example.com or *.example.com";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "192.168.1.1";
case CertSubjectAlternativeNameType.EMAIL:
return "[email protected]";
case CertSubjectAlternativeNameType.URI:
return "https://example.com";
default:
return "Enter value";
}
};
export const getSanTypeLabels = () => ({
[CertSubjectAlternativeNameType.DNS_NAME]: "DNS",
[CertSubjectAlternativeNameType.IP_ADDRESS]: "IP",
[CertSubjectAlternativeNameType.EMAIL]: "Email",
[CertSubjectAlternativeNameType.URI]: "URI"
});
export type SubjectAltName = {
type: CertSubjectAlternativeNameType;
value: string;
};
export const formatSubjectAltNames = (subjectAltNames: SubjectAltName[]) => {
return subjectAltNames
.filter((san) => san.value.trim())
.map((san) => ({
type: san.type,
value: san.value.trim()
}));
};
export const filterUsages = <T extends Record<string, boolean>>(usages: T): string[] => {
return Object.entries(usages)
.filter(([, value]) => value)
.map(([key]) => key);
};
export const getAttributeValue = (
subjectAttributes: Array<{ type: string; value: string }> | undefined,
type: string
): string => {
const foundAttr = subjectAttributes?.find((attr) => attr.type === type);
return foundAttr?.value || "";
};
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from "react";
import { UseFormSetValue, UseFormWatch } from "react-hook-form";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import {
CertSubjectAlternativeNameType,
mapTemplateKeyAlgorithmToApi,
mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
export type TemplateConstraints = {
allowedKeyUsages: string[];
allowedExtendedKeyUsages: string[];
requiredKeyUsages: string[];
requiredExtendedKeyUsages: string[];
allowedSignatureAlgorithms: string[];
allowedKeyAlgorithms: string[];
allowedSanTypes: CertSubjectAlternativeNameType[];
shouldShowSanSection: boolean;
shouldShowSubjectSection: boolean;
};
export const useCertificateTemplate = (
templateData: any,
selectedProfile: any,
isModalOpen: boolean,
setValue: UseFormSetValue<any>,
watch: UseFormWatch<any>
) => {
const [constraints, setConstraints] = useState<TemplateConstraints>({
allowedKeyUsages: [],
allowedExtendedKeyUsages: [],
requiredKeyUsages: [],
requiredExtendedKeyUsages: [],
allowedSignatureAlgorithms: [],
allowedKeyAlgorithms: [],
allowedSanTypes: [
CertSubjectAlternativeNameType.DNS_NAME,
CertSubjectAlternativeNameType.IP_ADDRESS,
CertSubjectAlternativeNameType.EMAIL,
CertSubjectAlternativeNameType.URI
],
shouldShowSanSection: true,
shouldShowSubjectSection: true
});
const filteredKeyUsages = useMemo(() => {
return KEY_USAGES_OPTIONS.filter(({ value }) => constraints.allowedKeyUsages.includes(value));
}, [constraints.allowedKeyUsages]);
const filteredExtendedKeyUsages = useMemo(() => {
return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) =>
constraints.allowedExtendedKeyUsages.includes(value)
);
}, [constraints.allowedExtendedKeyUsages]);
const availableSignatureAlgorithms = useMemo(() => {
return constraints.allowedSignatureAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm);
return {
value: apiAlgorithm,
label: apiAlgorithm
};
});
}, [constraints.allowedSignatureAlgorithms]);
const availableKeyAlgorithms = useMemo(() => {
return constraints.allowedKeyAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm);
return {
value: apiAlgorithm,
label: apiAlgorithm
};
});
}, [constraints.allowedKeyAlgorithms]);
const resetConstraints = () => {
setConstraints({
allowedKeyUsages: [],
allowedExtendedKeyUsages: [],
requiredKeyUsages: [],
requiredExtendedKeyUsages: [],
allowedSignatureAlgorithms: [],
allowedKeyAlgorithms: [],
allowedSanTypes: [
CertSubjectAlternativeNameType.DNS_NAME,
CertSubjectAlternativeNameType.IP_ADDRESS,
CertSubjectAlternativeNameType.EMAIL,
CertSubjectAlternativeNameType.URI
],
shouldShowSanSection: true,
shouldShowSubjectSection: true
});
};
useEffect(() => {
if (templateData && selectedProfile && isModalOpen) {
const newConstraints: TemplateConstraints = {
allowedSignatureAlgorithms: templateData.algorithms?.signature || [],
allowedKeyAlgorithms: templateData.algorithms?.keyAlgorithm || [],
allowedKeyUsages: [
...(templateData.keyUsages?.required || []),
...(templateData.keyUsages?.allowed || [])
],
allowedExtendedKeyUsages: [
...(templateData.extendedKeyUsages?.required || []),
...(templateData.extendedKeyUsages?.allowed || [])
],
requiredKeyUsages: templateData.keyUsages?.required || [],
requiredExtendedKeyUsages: templateData.extendedKeyUsages?.required || [],
allowedSanTypes: [],
shouldShowSanSection: true,
shouldShowSubjectSection: true
};
// Set TTL if available
if (templateData.validity?.max) {
setValue("ttl", templateData.validity.max);
}
// Handle SAN types
if (templateData.sans && templateData.sans.length > 0) {
const sanTypes: CertSubjectAlternativeNameType[] = [];
templateData.sans.forEach((sanPolicy: any) => {
if (!sanTypes.includes(sanPolicy.type)) {
sanTypes.push(sanPolicy.type);
}
});
newConstraints.allowedSanTypes = sanTypes;
newConstraints.shouldShowSanSection = true;
} else {
newConstraints.allowedSanTypes = [];
newConstraints.shouldShowSanSection = false;
setValue("subjectAltNames", []);
}
// Handle subject section
if (templateData.subject && templateData.subject.length > 0) {
newConstraints.shouldShowSubjectSection = true;
const currentSubjectAttrs = watch("subjectAttributes");
if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) {
setValue("subjectAttributes", [{ type: "common_name", value: "" }]);
}
} else {
newConstraints.shouldShowSubjectSection = false;
setValue("subjectAttributes", undefined);
}
setConstraints(newConstraints);
// Set initial required usages
const initialKeyUsages: Record<string, boolean> = {};
const initialExtendedKeyUsages: Record<string, boolean> = {};
(templateData.keyUsages?.required || []).forEach((usage: string) => {
initialKeyUsages[usage] = true;
});
(templateData.extendedKeyUsages?.required || []).forEach((usage: string) => {
initialExtendedKeyUsages[usage] = true;
});
setValue("keyUsages", initialKeyUsages);
setValue("extendedKeyUsages", initialExtendedKeyUsages);
}
}, [templateData, selectedProfile, setValue, watch, isModalOpen]);
return {
constraints,
filteredKeyUsages,
filteredExtendedKeyUsages,
availableSignatureAlgorithms,
availableKeyAlgorithms,
resetConstraints
};
};
@@ -7,6 +7,7 @@ import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub, useProject } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { ProjectType } from "@app/hooks/api/projects/types";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import { PkiSyncsTab } from "./components";
@@ -42,16 +43,18 @@ export const IntegrationsListPage = () => {
<meta property="og:title" content="Manage your certificates in seconds" />
<meta name="og:description" content="Sync and manage PKI certificates across services" />
</Helmet>
<div className="relative container mx-auto max-w-7xl pb-12 text-white">
<div className="relative mx-auto max-w-8xl pb-12 text-white">
<div className="mb-8">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Integrations"
description="Manage integrations with third-party certificate services."
/>
<Tabs value={currentTab} onValueChange={updateSelectedTab}>
<Tabs orientation="vertical" value={currentTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={IntegrationsListPageTabs.PkiSyncs}>Certificate Syncs</Tab>
<Tab variant="project" value={IntegrationsListPageTabs.PkiSyncs}>
Certificate Syncs
</Tab>
</TabList>
<TabPanel value={IntegrationsListPageTabs.PkiSyncs}>
<ProjectPermissionCan
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -19,6 +21,7 @@ import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { useDeletePkiCollection, useGetPkiCollectionById } from "@app/hooks/api";
import { PkiItemType } from "@app/hooks/api/pkiCollections/constants";
import { ProjectType } from "@app/hooks/api/projects/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiCollectionModal } from "../AlertingPage/components/PkiCollectionModal";
@@ -70,10 +73,24 @@ export const PkiCollectionPage = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={data.name}>
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/certificates"
params={{
projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Certificates
</Link>
<PageHeader
scope={ProjectType.CertificateManager}
title={data.name}
description="Manage certificate collection"
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -22,6 +24,7 @@ import {
useProject
} from "@app/context";
import { useDeletePkiSubscriber, useGetPkiSubscriber } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/projects/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiSubscriberModal } from "../PkiSubscribersPage/components/PkiSubscriberModal";
@@ -75,10 +78,24 @@ const Page = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={data.name}>
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Subscribers
</Link>
<PageHeader
scope={ProjectType.CertificateManager}
title={data.name}
description="Manage PKI subscriber"
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -2,6 +2,7 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiSubscriberSection } from "./components";
@@ -13,10 +14,10 @@ export const PkiSubscribersPage = () => {
<title>{t("common.head-title", { title: "PKI Subscribers" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Subscribers"
description="Manage subscribers that request and receive certificates, including user devices, servers, and services."
/>
@@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProject
useProject,
useSubscription
} from "@app/context";
import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api";
import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types";
@@ -18,7 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const projectId = currentProject.id;
const canCreateLegacySubscribers = subscription.pkiLegacyTemplates;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
@@ -100,23 +104,25 @@ export const PkiSubscriberSection = () => {
/>
</span>
</a>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Create}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("pkiSubscriber")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Subscriber
</Button>
)}
</ProjectPermissionCan>
{canCreateLegacySubscribers && (
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Create}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("pkiSubscriber")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Subscriber
</Button>
)}
</ProjectPermissionCan>
)}
</div>
</div>
<PkiSubscribersTable handlePopUpOpen={handlePopUpOpen} />
@@ -68,8 +68,8 @@ const PageContent = () => {
return (
<>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<Button
variant="link"
type="submit"
@@ -48,6 +48,7 @@ import {
import { usePopUp } from "@app/hooks";
import { useDeleteCertTemplateV2 } from "@app/hooks/api";
import { useListCertificateTemplates } from "@app/hooks/api/certificateTemplates/queries";
import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateTemplateEnrollmentModal } from "../CertificatesPage/components/CertificateTemplateEnrollmentModal";
import { PkiTemplateForm } from "./components/PkiTemplateForm";
@@ -58,6 +59,7 @@ export const PkiTemplateListPage = () => {
const { currentProject } = useProject();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"certificateTemplate",
"deleteTemplate",
@@ -103,37 +105,39 @@ export const PkiTemplateListPage = () => {
<title>{t("common.head-title", { title: "PKI Templates" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Certificate Templates"
description="Manage certificate template to request and issue dynamic certificates following a strict format."
/>
</div>
<div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
<div className="container mx-auto mb-6 max-w-8xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
{subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
</div>
)}
<TableContainer>
<Table>
<THead>
@@ -267,7 +271,7 @@ export const PkiTemplateListPage = () => {
onDeleteApproved={() => onRemovePkiSubscriberSubmit()}
/>
</div>
<div className="container mx-auto max-w-7xl" />
<div className="container mx-auto max-w-8xl" />
</div>
<Modal
isOpen={popUp?.certificateTemplate?.isOpen}
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
enum TabSections {
CertificateProfiles = "profiles",
CertificateTemplatesV2 = "templates-v2"
}
export const PoliciesPage = () => {
const { t } = useTranslation();
const { currentProject } = useProject();
const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles);
if (!currentProject) {
return <ContentLoader />;
}
return (
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
if (!isAllowed) {
return (
<div className="mx-auto flex h-full flex-col justify-center bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl text-center">
<p>You don&apos;t have permission to access certificate policies.</p>
</div>
</div>
);
}
return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Policies" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificate Policies"
description="Manage certificate templates and profiles for unified certificate issuance"
/>
<Tabs
orientation="vertical"
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.CertificateProfiles}>
Certificate Profiles
</Tab>
<Tab variant="project" value={TabSections.CertificateTemplatesV2}>
Certificate Templates
</Tab>
</TabList>
<TabPanel value={TabSections.CertificateProfiles}>
<CertificateProfilesTab />
</TabPanel>
<TabPanel value={TabSections.CertificateTemplatesV2}>
<CertificateTemplatesV2Tab />
</TabPanel>
</Tabs>
</div>
</div>
);
}}
</ProjectPermissionCan>
);
};
@@ -0,0 +1,127 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
TCertificateProfileWithDetails,
useDeleteCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { CreateProfileModal } from "./CreateProfileModal";
import { ProfileList } from "./ProfileList";
export const CertificateProfilesTab = () => {
const { permission } = useProjectPermission();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<TCertificateProfileWithDetails | null>(
null
);
const deleteProfile = useDeleteCertificateProfile();
const canCreateProfile = permission.can(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateAuthorities
);
const handleCreateProfile = () => {
setIsCreateModalOpen(true);
};
const handleEditProfile = (profile: TCertificateProfileWithDetails) => {
setSelectedProfile(profile);
setIsEditModalOpen(true);
};
const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => {
setSelectedProfile(profile);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!selectedProfile) return;
try {
await deleteProfile.mutateAsync({
profileId: selectedProfile.id
});
setIsDeleteModalOpen(false);
setSelectedProfile(null);
createNotification({
text: `Certificate profile "${selectedProfile.slug}" deleted successfully`,
type: "success"
});
} catch (error) {
console.error(
`Failed to delete profile "${selectedProfile.slug}" (ID: ${selectedProfile.id}):`,
error
);
}
};
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>
<h2 className="text-xl font-semibold text-mineshaft-100">Certificate Profiles</h2>
<p className="text-sm text-bunker-300">
Unified certificate issuance configurations combining CA, template, and enrollment
method
</p>
</div>
{canCreateProfile && (
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreateProfile}
>
Create Profile
</Button>
)}
</div>
<ProfileList onEditProfile={handleEditProfile} onDeleteProfile={handleDeleteProfile} />
<CreateProfileModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
{selectedProfile && (
<>
<CreateProfileModal
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setSelectedProfile(null);
}}
profile={selectedProfile}
mode="edit"
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete Certificate Profile ${selectedProfile.slug}?`}
onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen);
if (!isOpen) {
setSelectedProfile(null);
}
}}
deleteKey={selectedProfile.slug}
onDeleteApproved={handleDeleteConfirm}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,609 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import {
TCertificateProfileWithDetails,
TCreateCertificateProfileDTO,
TUpdateCertificateProfileDTO,
useCreateCertificateProfile,
useUpdateCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
const createSchema = z
.object({
slug: z
.string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
certificateTemplateId: z.string().min(1, "Certificate Template is required"),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().min(1, "EST passphrase is required"),
caChain: z.string().min(1, "EST CA chain is required").optional()
})
.refine(
(data) => {
if (!data.disableBootstrapCaValidation && !data.caChain) {
return false;
}
return true;
},
{
message: "EST CA chain is required when bootstrap CA validation is enabled",
path: ["caChain"]
}
)
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === "est" && !data.estConfig) {
return false;
}
if (data.enrollmentType === "api" && !data.apiConfig) {
return false;
}
return true;
},
{
message: "Configuration is required for selected enrollment type"
}
);
const editSchema = z
.object({
slug: z
.string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().optional(),
certificateTemplateId: z.string().optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().optional(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === "est" && !data.estConfig) {
return false;
}
if (data.enrollmentType === "api" && !data.apiConfig) {
return false;
}
return true;
},
{
message: "Configuration is required for selected enrollment type"
}
);
export type FormData = z.infer<typeof createSchema>;
interface Props {
isOpen: boolean;
onClose: () => void;
profile?: TCertificateProfileWithDetails;
mode?: "create" | "edit";
}
export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => {
const { currentProject } = useProject();
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
const { data: templateData } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "",
limit: 100,
offset: 0
});
const createProfile = useCreateCertificateProfile();
const updateProfile = useUpdateCertificateProfile();
const isEdit = mode === "edit" && profile;
const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || [];
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit
? {
slug: profile.slug,
description: profile.description || "",
enrollmentType: profile.enrollmentType,
certificateAuthorityId: profile.caId,
certificateTemplateId: profile.certificateTemplateId,
estConfig:
profile.enrollmentType === "est"
? {
disableBootstrapCaValidation:
profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: profile.estConfig?.passphrase || "",
caChain: profile.estConfig?.caChain || ""
}
: undefined,
apiConfig:
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
: undefined
}
: {
slug: "",
description: "",
enrollmentType: "api",
certificateAuthorityId: "",
certificateTemplateId: "",
apiConfig: {
autoRenew: false,
autoRenewDays: 30
}
}
});
const watchedEnrollmentType = watch("enrollmentType");
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew");
useEffect(() => {
if (isEdit && profile) {
reset({
slug: profile.slug,
description: profile.description || "",
enrollmentType: profile.enrollmentType,
certificateAuthorityId: profile.caId,
certificateTemplateId: profile.certificateTemplateId,
estConfig:
profile.enrollmentType === "est"
? {
disableBootstrapCaValidation:
profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: profile.estConfig?.passphrase || "",
caChain: profile.estConfig?.caChain || ""
}
: undefined,
apiConfig:
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
: undefined
});
}
}, [isEdit, profile, reset]);
const onFormSubmit = async (data: FormData) => {
try {
if (!currentProject?.id && !isEdit) return;
if (isEdit) {
const updateData: TUpdateCertificateProfileDTO = {
profileId: profile.id,
slug: data.slug,
description: data.description
};
if (data.enrollmentType === "est" && data.estConfig) {
updateData.estConfig = data.estConfig;
} else if (data.enrollmentType === "api" && data.apiConfig) {
updateData.apiConfig = data.apiConfig;
}
await updateProfile.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
throw new Error("Project ID is required for creating a profile");
}
const createData: TCreateCertificateProfileDTO = {
projectId: currentProject.id,
slug: data.slug,
description: data.description,
enrollmentType: data.enrollmentType,
caId: data.certificateAuthorityId,
certificateTemplateId: data.certificateTemplateId
};
if (data.enrollmentType === "est" && data.estConfig) {
createData.estConfig = {
passphrase: data.estConfig.passphrase,
caChain: data.estConfig.caChain || undefined,
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation
};
} else if (data.enrollmentType === "api" && data.apiConfig) {
createData.apiConfig = data.apiConfig;
}
await createProfile.mutateAsync(createData);
}
createNotification({
text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`,
type: "success"
});
reset();
onClose();
} catch (error) {
console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error);
createNotification({
text: `Failed to ${isEdit ? "update" : "create"} certificate profile`,
type: "error"
});
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
}
onClose();
}}
>
<ModalContent
title={isEdit ? "Edit Certificate Profile" : "Create Certificate Profile"}
subTitle={
isEdit
? `Update configuration for ${profile?.slug}`
: "Configure a new certificate profile for unified certificate issuance"
}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="your-profile-name" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
<TextArea {...field} placeholder="Enter profile description" rows={3} />
</FormControl>
)}
/>
<Controller
control={control}
name="certificateAuthorityId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={onChange}
placeholder="Select a certificate authority"
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
{certificateAuthorities.map((ca) => (
<SelectItem key={ca.id} value={ca.id}>
{ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName
: ca.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="certificateTemplateId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Template"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={(value) => {
if (watchedEnrollmentType === "est") {
setValue("estConfig", {
disableBootstrapCaValidation: false,
passphrase: ""
});
setValue("apiConfig", undefined);
} else {
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
});
setValue("estConfig", undefined);
}
onChange(value);
}}
placeholder="Select a certificate template"
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
{certificateTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="enrollmentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Enrollment Type"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={(value) => {
if (value === "est") {
setValue("apiConfig", undefined);
setValue("estConfig", {
disableBootstrapCaValidation: false,
passphrase: ""
});
} else {
setValue("estConfig", undefined);
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
});
}
onChange(value);
}}
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
<SelectItem value="api">API</SelectItem>
<SelectItem value="est">EST</SelectItem>
</Select>
</FormControl>
)}
/>
{/* EST Configuration */}
{watchedEnrollmentType === "est" && (
<div className="mb-4 space-y-4">
<div className="space-y-4">
<Controller
control={control}
name="estConfig.disableBootstrapCaValidation"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
<Checkbox
id="disableBootstrapCaValidation"
isChecked={value}
onCheckedChange={onChange}
/>
<div className="space-y-1">
<span className="text-sm font-medium text-mineshaft-100">
Disable Bootstrap CA Validation
</span>
<p className="text-xs text-bunker-300">
Skip CA certificate validation during EST bootstrap phase
</p>
</div>
</div>
</FormControl>
)}
/>
<Controller
control={control}
name="estConfig.passphrase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="EST Passphrase"
isRequired={!isEdit}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="password"
placeholder="Enter secure passphrase for EST authentication"
className="w-full"
/>
</FormControl>
)}
/>
{!watchedDisableBootstrapValidation && (
<Controller
control={control}
name="estConfig.caChain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Chain Certificate"
isRequired={!isEdit}
isError={Boolean(error)}
errorText={error?.message}
>
<div className="space-y-2">
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE-----&#10;MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV&#10;BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX&#10;...&#10;-----END CERTIFICATE-----"
rows={6}
className="w-full font-mono text-xs"
/>
<p className="text-xs text-bunker-400">
Paste the complete CA certificate chain in PEM format
</p>
</div>
</FormControl>
)}
/>
)}
</div>
</div>
)}
{/* API Configuration */}
{watchedEnrollmentType === "api" && (
<div className="mb-4 space-y-4">
<Controller
control={control}
name="apiConfig.autoRenew"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
Enable Auto-Renewal
</Checkbox>
</FormControl>
)}
/>
</div>
)}
{watchedAutoRenew && (
<div className="mb-4 space-y-4">
<Controller
control={control}
name="apiConfig.autoRenewDays"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Auto-Renewal Days"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
placeholder="30"
min="1"
max="365"
className="w-full"
isDisabled={!watchedAutoRenew}
onChange={(e) => {
const { value } = e.target;
if (value === "") {
field.onChange("");
} else {
const parsed = parseInt(value, 10);
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 365) {
field.onChange(parsed);
} else {
field.onChange(field.value || "");
}
}
}}
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex gap-3">
<Button
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateProfile.isPending : createProfile.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateProfile.isPending : createProfile.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
<Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateProfile.isPending : createProfile.isPending}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,101 @@
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
TCertificateProfileWithDetails,
useListCertificateProfiles
} from "@app/hooks/api/certificateProfiles";
import { ProfileRow } from "./ProfileRow";
interface Props {
onEditProfile: (profile: TCertificateProfileWithDetails) => void;
onDeleteProfile: (profile: TCertificateProfileWithDetails) => void;
}
export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
const { currentProject } = useProject();
const { data, isLoading } = useListCertificateProfiles({
projectId: currentProject?.id || "",
limit: 100,
offset: 0,
includeConfigs: true,
includeMetrics: true
});
const profiles = data?.certificateProfiles || [];
if (!currentProject?.id) {
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
<Tr>
<Td colSpan={6}>
<EmptyState title="No Project Selected" />
</Td>
</Tr>
</TBody>
</Table>
</TableContainer>
);
}
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={6} innerKey="certificate-profiles" />}
{!isLoading && (!profiles || profiles.length === 0) && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No Certificate Profiles" />
</Td>
</Tr>
)}
{!isLoading &&
profiles &&
profiles.length > 0 &&
profiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
onEditProfile={onEditProfile}
onDeleteProfile={onDeleteProfile}
/>
))}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1,236 @@
import { useCallback } from "react";
import {
faCheck,
faCircleInfo,
faCopy,
faEdit,
faEllipsis,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { usePopUp, useToggle } from "@app/hooks";
import { useGetCaById } from "@app/hooks/api/ca/queries";
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
const MetricsBadges = ({
metrics
}: {
metrics?: {
totalCertificates: number;
activeCertificates: number;
expiringCertificates: number;
expiredCertificates: number;
revokedCertificates: number;
};
}) => {
if (!metrics) {
return (
<Badge variant="primary" className="text-xs">
No metrics
</Badge>
);
}
if (metrics.totalCertificates === 0) {
return (
<Badge variant="primary" className="text-xs">
No certificates
</Badge>
);
}
return (
<>
{metrics.activeCertificates > 0 && (
<Badge variant="success" className="text-xs">
{metrics.activeCertificates} active
</Badge>
)}
{metrics.expiringCertificates > 0 && (
<Badge variant="primary" className="text-xs">
{metrics.expiringCertificates} expiring
</Badge>
)}
{metrics.expiredCertificates > 0 && (
<Badge variant="danger" className="text-xs">
{metrics.expiredCertificates} expired
</Badge>
)}
{metrics.revokedCertificates > 0 && (
<Badge variant="danger" className="text-xs">
{metrics.revokedCertificates} revoked
</Badge>
)}
</>
);
};
interface Props {
profile: TCertificateProfile;
onEditProfile: (profile: TCertificateProfile) => void;
onDeleteProfile: (profile: TCertificateProfile) => void;
}
export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => {
const { permission } = useProjectPermission();
const { data: caData } = useGetCaById(profile.caId);
const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const);
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(profile.id);
createNotification({
text: "Profile ID copied to clipboard",
type: "info"
});
setTimeout(() => setIsIdCopied.off(), 2000);
}, [setIsIdCopied]);
const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: profile.certificateTemplateId
});
const canEditProfile = permission.can(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateAuthorities
);
const canIssueCertificate = permission.can(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
const canDeleteProfile = permission.can(
ProjectPermissionActions.Delete,
ProjectPermissionSub.CertificateAuthorities
);
const getEnrollmentTypeBadge = (enrollmentType: string) => {
const config = {
api: { variant: "success" as const, label: "API" },
est: { variant: "primary" as const, label: "EST" }
} as const;
const configKey = Object.keys(config).includes(enrollmentType)
? (enrollmentType as keyof typeof config)
: "api";
const { variant, label } = config[configKey];
return <Badge variant={variant}>{label}</Badge>;
};
return (
<Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700">
<Td>
<div className="flex items-center gap-2">
<div className="text-mineshaft-300">{profile.slug}</div>
{profile.description && (
<Tooltip content={profile.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-start">
<span className="text-sm text-mineshaft-300">
{caData?.friendlyName || caData?.commonName || profile.caId}
</span>
</Td>
<Td>
<span className="text-sm text-mineshaft-300">
{templateData?.name || profile.certificateTemplateId}
</span>
</Td>
<Td>
<div className="flex flex-wrap gap-1">
<MetricsBadges metrics={profile.metrics} />
</div>
</Td>
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Profile ID
</DropdownMenuItem>
{canEditProfile && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEditProfile(profile);
}}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit Profile
</DropdownMenuItem>
)}
{canIssueCertificate && profile.enrollmentType === "api" && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpToggle("certificateIssuance");
}}
icon={<FontAwesomeIcon icon={faPlus} />}
>
Issue Certificate
</DropdownMenuItem>
)}
{canDeleteProfile && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteProfile(profile);
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Profile
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<CertificateIssuanceModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
profileId={profile.id}
/>
</Td>
</Tr>
);
};
@@ -0,0 +1,3 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CreateProfileModal } from "./CreateProfileModal";
export { ProfileList } from "./ProfileList";
@@ -0,0 +1,123 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useDeleteCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/mutations";
import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types";
import { CreateTemplateModal } from "./CreateTemplateModal";
import { TemplateList } from "./TemplateList";
export const CertificateTemplatesV2Tab = () => {
const { permission } = useProjectPermission();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] =
useState<TCertificateTemplateV2WithPolicies | null>(null);
const deleteTemplateV2 = useDeleteCertificateTemplateV2WithPolicies();
const canCreateTemplate = permission.can(
ProjectPermissionPkiTemplateActions.Create,
ProjectPermissionSub.CertificateTemplates
);
const handleCreateTemplate = () => {
setIsCreateModalOpen(true);
};
const handleEditTemplate = (template: TCertificateTemplateV2WithPolicies) => {
setSelectedTemplate(template);
setIsEditModalOpen(true);
};
const handleDeleteTemplate = (template: TCertificateTemplateV2WithPolicies) => {
setSelectedTemplate(template);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!selectedTemplate) return;
try {
await deleteTemplateV2.mutateAsync({
templateId: selectedTemplate.id
});
setIsDeleteModalOpen(false);
setSelectedTemplate(null);
createNotification({
text: `Certificate template "${selectedTemplate.name}" deleted successfully`,
type: "success"
});
} catch (error) {
console.error("Failed to delete template:", error);
createNotification({
text: "Failed to delete certificate template",
type: "error"
});
}
};
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>
<h2 className="text-xl font-semibold text-mineshaft-100">Certificate Templates</h2>
<p className="text-sm text-bunker-300">
Define certificate policies, validation rules, and attribute constraints for certificate
issuance
</p>
</div>
{canCreateTemplate && (
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreateTemplate}
>
Create Template
</Button>
)}
</div>
<TemplateList onEditTemplate={handleEditTemplate} onDeleteTemplate={handleDeleteTemplate} />
<CreateTemplateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
{selectedTemplate && (
<>
<CreateTemplateModal
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setSelectedTemplate(null);
}}
template={selectedTemplate}
mode="edit"
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete Certificate Template ${selectedTemplate.name}?`}
onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedTemplate(null);
}}
deleteKey={selectedTemplate.name}
onDeleteApproved={handleDeleteConfirm}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,961 @@
/* eslint-disable no-nested-ternary */
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
useCreateCertificateTemplateV2WithPolicies,
useUpdateCertificateTemplateV2WithPolicies
} from "@app/hooks/api/certificateTemplates/mutations";
import {
TCertificateTemplateV2Policy,
TCertificateTemplateV2WithPolicies
} from "@app/hooks/api/certificateTemplates/types";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType,
SAN_INCLUDE_OPTIONS,
SAN_TYPE_OPTIONS,
SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS,
SUBJECT_ATTRIBUTE_TYPE_OPTIONS
} from "./shared/certificate-constants";
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
export type FormData = TemplateFormData;
type AttributeTransform = NonNullable<TCertificateTemplateV2Policy["subject"]>[0];
type SanTransform = NonNullable<TCertificateTemplateV2Policy["sans"]>[0];
type KeyUsagesTransform = TCertificateTemplateV2Policy["keyUsages"];
type ExtendedKeyUsagesTransform = TCertificateTemplateV2Policy["extendedKeyUsages"];
type AlgorithmsTransform = TCertificateTemplateV2Policy["algorithms"];
type ValidityTransform = TCertificateTemplateV2Policy["validity"];
interface Props {
isOpen: boolean;
onClose: () => void;
template?: TCertificateTemplateV2WithPolicies;
mode?: "create" | "edit";
}
const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = {
common_name: "Common Name (CN)"
};
const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = {
dns_name: "DNS Name",
ip_address: "IP Address",
email: "Email",
uri: "URI"
};
const SUBJECT_ATTRIBUTE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number], string> =
{
optional: "Allow",
prohibit: "Deny"
};
const SAN_INCLUDE_LABELS: Record<(typeof SAN_INCLUDE_OPTIONS)[number], string> = {
mandatory: "Require",
optional: "Allow",
prohibit: "Deny"
};
const SIGNATURE_ALGORITHMS = [
"SHA256-RSA",
"SHA384-RSA",
"SHA512-RSA",
"SHA256-ECDSA",
"SHA384-ECDSA",
"SHA512-ECDSA"
];
const KEY_ALGORITHMS = [
"RSA-2048",
"RSA-3072",
"RSA-4096",
"ECDSA-P256",
"ECDSA-P384",
"ECDSA-P521"
];
export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }: Props) => {
const { currentProject } = useProject();
const createTemplate = useCreateCertificateTemplateV2WithPolicies();
const updateTemplate = useUpdateCertificateTemplateV2WithPolicies();
const isEdit = mode === "edit" && template;
const convertApiToUiFormat = (templateData: TCertificateTemplateV2WithPolicies): FormData => {
const attributes: FormData["attributes"] = [];
if (templateData.subject && Array.isArray(templateData.subject)) {
templateData.subject.forEach((subj) => {
if (subj.allowed && Array.isArray(subj.allowed)) {
subj.allowed.forEach((allowedValue) => {
attributes.push({
type: subj.type as CertSubjectAttributeType,
include: "optional",
value: [allowedValue]
});
});
}
if (subj.denied && Array.isArray(subj.denied)) {
subj.denied.forEach((deniedValue) => {
attributes.push({
type: subj.type as CertSubjectAttributeType,
include: "prohibit",
value: [deniedValue]
});
});
}
});
}
const subjectAlternativeNames: FormData["subjectAlternativeNames"] = [];
if (templateData.sans && Array.isArray(templateData.sans)) {
templateData.sans.forEach((san) => {
if (san.required && Array.isArray(san.required)) {
san.required.forEach((requiredValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "mandatory",
value: [requiredValue]
});
});
}
if (san.allowed && Array.isArray(san.allowed)) {
san.allowed.forEach((allowedValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "optional",
value: [allowedValue]
});
});
}
if (san.denied && Array.isArray(san.denied)) {
san.denied.forEach((deniedValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "prohibit",
value: [deniedValue]
});
});
}
});
}
const keyUsages = {
requiredUsages: (templateData.keyUsages?.required || []) as CertKeyUsageType[],
optionalUsages: (templateData.keyUsages?.allowed || []) as CertKeyUsageType[]
};
const extendedKeyUsages = {
requiredUsages: (templateData.extendedKeyUsages?.required ||
[]) as CertExtendedKeyUsageType[],
optionalUsages: (templateData.extendedKeyUsages?.allowed || []) as CertExtendedKeyUsageType[]
};
const validity = templateData.validity?.max
? (() => {
const maxValue = templateData.validity.max;
if (maxValue.length < 2) return undefined;
const lastChar = maxValue.slice(-1);
const numberPart = maxValue.slice(0, -1);
const value = parseInt(numberPart, 10);
if (Number.isNaN(value) || value <= 0 || numberPart !== value.toString()) {
return undefined;
}
let unit: CertDurationUnit = CertDurationUnit.DAYS;
if (lastChar === "d") {
unit = CertDurationUnit.DAYS;
} else if (lastChar === "m") {
unit = CertDurationUnit.MONTHS;
} else if (lastChar === "y") {
unit = CertDurationUnit.YEARS;
} else {
return undefined;
}
return {
maxDuration: { value, unit }
};
})()
: { maxDuration: { value: 365, unit: CertDurationUnit.DAYS } };
const signatureAlgorithm = {
allowedAlgorithms: templateData.algorithms?.signature || [],
defaultAlgorithm: ""
};
const keyAlgorithm = {
allowedKeyTypes: templateData.algorithms?.keyAlgorithm || [],
defaultKeyType: ""
};
return {
name: templateData.name || "",
description: templateData.description || "",
attributes,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
validity,
signatureAlgorithm,
keyAlgorithm
};
};
const getDefaultValues = (): FormData => ({
name: "",
description: "",
attributes: [],
keyUsages: { requiredUsages: [], optionalUsages: [] },
extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
subjectAlternativeNames: [],
validity: {
maxDuration: { value: 365, unit: CertDurationUnit.DAYS }
},
signatureAlgorithm: {
allowedAlgorithms: []
},
keyAlgorithm: {
allowedKeyTypes: []
}
});
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(templateSchema),
defaultValues: getDefaultValues()
});
useEffect(() => {
if (isEdit && template) {
const convertedData = convertApiToUiFormat(template);
reset(convertedData);
} else if (!isEdit) {
reset(getDefaultValues());
}
}, [isEdit, template, reset]);
const watchedAttributes = watch("attributes") || [];
const watchedSans = watch("subjectAlternativeNames") || [];
const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] };
const watchedExtendedKeyUsages = watch("extendedKeyUsages") || {
requiredUsages: [],
optionalUsages: []
};
const consolidateByType = <
T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] }
>(
items: T[]
): T[] => {
const consolidated = new Map<string, T>();
items.forEach((item) => {
const existing = consolidated.get(item.type);
if (existing) {
const mergedItem = {
...item,
allowed: [...new Set([...(existing.allowed || []), ...(item.allowed || [])])],
required: [...new Set([...(existing.required || []), ...(item.required || [])])],
denied: [...new Set([...(existing.denied || []), ...(item.denied || [])])]
} as T;
if (mergedItem.allowed?.length === 0) delete mergedItem.allowed;
if (mergedItem.required?.length === 0) delete mergedItem.required;
if (mergedItem.denied?.length === 0) delete mergedItem.denied;
consolidated.set(item.type, mergedItem);
} else {
consolidated.set(item.type, item);
}
});
return Array.from(consolidated.values());
};
const transformToApiFormat = (data: FormData) => {
const subjectRaw =
data.attributes?.map((attr) => {
const result: AttributeTransform = { type: attr.type };
if (attr.include === "optional" && attr.value && attr.value.length > 0) {
result.allowed = attr.value;
} else if (attr.include === "prohibit" && attr.value && attr.value.length > 0) {
result.denied = attr.value;
}
return result;
}) || [];
const sansRaw =
data.subjectAlternativeNames?.map((san) => {
const result: SanTransform = { type: san.type };
if (san.include === "mandatory" && san.value && san.value.length > 0) {
result.required = san.value;
} else if (san.include === "optional" && san.value && san.value.length > 0) {
result.allowed = san.value;
} else if (san.include === "prohibit" && san.value && san.value.length > 0) {
result.denied = san.value;
}
return result;
}) || [];
const subject = consolidateByType(subjectRaw);
const sans = consolidateByType(sansRaw);
const keyUsages: KeyUsagesTransform = {
required: [],
allowed: []
};
if (data.keyUsages?.requiredUsages && data.keyUsages.requiredUsages.length > 0) {
keyUsages.required = data.keyUsages.requiredUsages;
}
if (data.keyUsages?.optionalUsages && data.keyUsages.optionalUsages.length > 0) {
keyUsages.allowed = data.keyUsages.optionalUsages;
}
const extendedKeyUsages: ExtendedKeyUsagesTransform = {
required: [],
allowed: []
};
if (
data.extendedKeyUsages?.requiredUsages &&
data.extendedKeyUsages.requiredUsages.length > 0
) {
extendedKeyUsages.required = data.extendedKeyUsages.requiredUsages;
}
if (
data.extendedKeyUsages?.optionalUsages &&
data.extendedKeyUsages.optionalUsages.length > 0
) {
extendedKeyUsages.allowed = data.extendedKeyUsages.optionalUsages;
}
const algorithms: AlgorithmsTransform = {};
if (
data.signatureAlgorithm?.allowedAlgorithms &&
data.signatureAlgorithm.allowedAlgorithms.length > 0
) {
algorithms.signature = data.signatureAlgorithm.allowedAlgorithms as Array<
| "SHA256-RSA"
| "SHA384-RSA"
| "SHA512-RSA"
| "SHA256-ECDSA"
| "SHA384-ECDSA"
| "SHA512-ECDSA"
>;
}
if (data.keyAlgorithm?.allowedKeyTypes && data.keyAlgorithm.allowedKeyTypes.length > 0) {
algorithms.keyAlgorithm = data.keyAlgorithm.allowedKeyTypes as Array<
"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384"
>;
}
const validity: ValidityTransform = {};
if (data.validity?.maxDuration) {
let unit = "d";
if (data.validity.maxDuration.unit === CertDurationUnit.DAYS) {
unit = "d";
} else if (data.validity.maxDuration.unit === CertDurationUnit.MONTHS) {
unit = "m";
} else {
unit = "y";
}
validity.max = `${data.validity.maxDuration.value}${unit}`;
}
return {
name: data.name,
description: data.description,
subject,
sans,
keyUsages,
extendedKeyUsages,
algorithms,
validity
};
};
const onFormSubmit = async (data: FormData) => {
try {
if (!currentProject?.id && !isEdit) return;
const hasEmptyAttributeValues = data.attributes?.some(
(attr) => !attr.value || attr.value.length === 0 || attr.value.some((v) => !v.trim())
);
const hasEmptySanValues = data.subjectAlternativeNames?.some(
(san) => !san.value || san.value.length === 0 || san.value.some((v) => !v.trim())
);
if (hasEmptyAttributeValues || hasEmptySanValues) {
createNotification({
text: "All values must be non-empty. Use wildcards (*) if needed.",
type: "error"
});
return;
}
const transformedData = transformToApiFormat(data);
if (isEdit) {
const updateData = {
templateId: template.id,
...transformedData
};
await updateTemplate.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
throw new Error("Project ID is required for creating a template");
}
const createData = {
projectId: currentProject.id,
...transformedData
};
await createTemplate.mutateAsync(createData);
}
createNotification({
text: `Certificate template ${isEdit ? "updated" : "created"} successfully`,
type: "success"
});
reset();
onClose();
} catch (error) {
console.error(`Error ${isEdit ? "updating" : "creating"} template:`, error);
createNotification({
text: `Failed to ${isEdit ? "update" : "create"} certificate template`,
type: "error"
});
}
};
const addAttribute = () => {
const newAttribute = {
type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0],
include: SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS[0],
value: ["*"]
};
setValue("attributes", [...watchedAttributes, newAttribute]);
};
const removeAttribute = (index: number) => {
const newAttributes = watchedAttributes.filter((_, i) => i !== index);
setValue("attributes", newAttributes);
};
const addSan = () => {
const newSan = {
type: SAN_TYPE_OPTIONS[0],
include: SAN_INCLUDE_OPTIONS[1],
value: ["*"]
};
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
};
const removeSan = (index: number) => {
const newSans = watchedSans.filter((_, i) => i !== index);
setValue("subjectAlternativeNames", newSans);
};
const handleKeyUsagesChange = (usages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
}) => {
setValue("keyUsages", {
requiredUsages: usages.requiredUsages,
optionalUsages: usages.optionalUsages
});
};
const handleExtendedKeyUsagesChange = (usages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
}) => {
setValue("extendedKeyUsages", {
requiredUsages: usages.requiredUsages,
optionalUsages: usages.optionalUsages
});
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
}
onClose();
}}
>
<ModalContent
className="max-w-4xl"
title={isEdit ? "Edit Certificate Template" : "Create Certificate Template"}
subTitle={
isEdit
? `Update configuration for ${template?.name}`
: "Define comprehensive certificate policies, validation rules, and constraints"
}
>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<Accordion type="multiple" defaultValue={["basic", "algorithms"]} className="w-full">
<div className="space-y-4">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Enter template name" className="w-full" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
<AccordionItem value="attributes">
<AccordionTrigger>Subject Attributes</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addAttribute}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Attribute
</Button>
</div>
<div className="space-y-2">
{watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
started.
</div>
) : (
watchedAttributes.map((attr, index) => {
return (
<div
// eslint-disable-next-line react/no-array-index-key
key={`attr-${attr.type}-${attr.include}-${index}`}
className="flex items-start gap-2"
>
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
type: value as CertSubjectAttributeType
};
setValue("attributes", newAttributes);
}}
className="w-48"
>
{SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
include:
value as (typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number]
};
setValue("attributes", newAttributes);
}}
className="w-32"
>
{SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SUBJECT_ATTRIBUTE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("attributes", newAttributes);
}}
className={`flex-1 ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
{watchedAttributes.length > 0 && (
<IconButton
ariaLabel="Remove Attribute"
variant="plain"
size="sm"
onClick={() => removeAttribute(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
);
})
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="san" className="mt-4">
<AccordionTrigger>Subject Alternative Names</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addSan}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SAN
</Button>
</div>
<div className="space-y-2">
{watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to
get started.
</div>
) : (
watchedSans.map((san, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`san-${san.type}-${san.include}-${index}`}
className="flex items-start gap-2"
>
<Select
value={san.type}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
type: value as CertSubjectAlternativeNameType
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-36"
>
{SAN_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={san.include}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
include: value as (typeof SAN_INCLUDE_OPTIONS)[number]
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-32"
>
{SAN_INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_INCLUDE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={san.value?.[0] || ""}
onChange={(e) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("subjectAlternativeNames", newSans);
}}
className={`flex-1 ${
san.value && san.value.length > 0 && san.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
{watchedSans.length > 0 && (
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => removeSan(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
))
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="algorithms" className="mt-4">
<AccordionTrigger>Algorithms</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Signature Algorithms
<span className="ml-1 text-red-500">*</span>
</h4>
<Controller
control={control}
name="signatureAlgorithm.allowedAlgorithms"
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="grid grid-cols-2 gap-2">
{SIGNATURE_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<div key={alg} className="flex items-center space-x-3">
<Checkbox
id={`sig-alg-${alg}`}
isChecked={isSelected}
onCheckedChange={(checked) => {
const current = field.value || [];
let newValue;
if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
field.onChange(newValue);
}}
/>
<label
htmlFor={`sig-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
>
{alg}
</label>
</div>
);
})}
</div>
</FormControl>
)}
/>
</div>
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Key Algorithms
<span className="ml-1 text-red-500">*</span>
</h4>
<Controller
control={control}
name="keyAlgorithm.allowedKeyTypes"
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="grid grid-cols-2 gap-2">
{KEY_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<div key={alg} className="flex items-center space-x-3">
<Checkbox
id={`key-alg-${alg}`}
isChecked={isSelected}
onCheckedChange={(checked) => {
const current = field.value || [];
let newValue;
if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
field.onChange(newValue);
}}
/>
<label
htmlFor={`key-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
>
{alg}
</label>
</div>
);
})}
</div>
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="usages" className="mt-4">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<KeyUsagesSection
watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
onKeyUsagesChange={handleKeyUsagesChange}
onExtendedKeyUsagesChange={handleExtendedKeyUsagesChange}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="validity" className="mt-4">
<AccordionTrigger>Certificate Validity</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="validity.maxDuration.value"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max Duration"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
placeholder="365"
className="w-full"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="validity.maxDuration.unit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Unit"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={field.onChange}
className="w-full"
position="popper"
>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="months">Months</SelectItem>
<SelectItem value="years">Years</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex gap-3">
<Button
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateTemplate.isPending : createTemplate.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
<Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateTemplate.isPending : createTemplate.isPending}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

Some files were not shown because too many files have changed in this diff Show More