mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-10 13:28:34 +00:00
Merge remote-tracking branch 'origin/main' into feat/blockDuplicateSyncDestination
This commit is contained in:
@@ -8,22 +8,40 @@ type Props = {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
text: string;
|
||||
isEnterpriseFeature?: boolean;
|
||||
};
|
||||
|
||||
export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Element => {
|
||||
export const UpgradePlanModal = ({
|
||||
text,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
isEnterpriseFeature = false
|
||||
}: Props): JSX.Element => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { mutateAsync, isPending } = useGetOrgTrialUrl();
|
||||
const link =
|
||||
subscription && subscription.slug !== null
|
||||
? ("/organization/billing" as const)
|
||||
: "https://infisical.com/scheduledemo";
|
||||
|
||||
const getLink = () => {
|
||||
// self-hosting
|
||||
if (!subscription || subscription.slug === null) {
|
||||
return "https://infisical.com/scheduledemo";
|
||||
}
|
||||
|
||||
// Infisical cloud
|
||||
if (isEnterpriseFeature) {
|
||||
return "https://infisical.com/talk-to-us";
|
||||
}
|
||||
|
||||
return "/organization/billing" as const;
|
||||
};
|
||||
|
||||
const link = getLink();
|
||||
|
||||
const handleUpgradeBtnClick = async () => {
|
||||
try {
|
||||
if (!subscription || !currentOrg) return;
|
||||
|
||||
if (!subscription.has_used_trial) {
|
||||
if (!subscription.has_used_trial && !isEnterpriseFeature) {
|
||||
// direct user to start pro trial
|
||||
|
||||
const url = await mutateAsync({
|
||||
@@ -40,6 +58,17 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
const getUpgradePlanLabel = () => {
|
||||
if (subscription) {
|
||||
if (isEnterpriseFeature) {
|
||||
return "Talk to Us";
|
||||
}
|
||||
if (!subscription.has_used_trial) {
|
||||
return "Start Pro Free Trial";
|
||||
}
|
||||
}
|
||||
return "Upgrade Plan";
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -55,7 +84,7 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele
|
||||
onClick={handleUpgradeBtnClick}
|
||||
className="mr-4"
|
||||
>
|
||||
{subscription && !subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"}
|
||||
{getUpgradePlanLabel()}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
BanIcon,
|
||||
BoxesIcon,
|
||||
BoxIcon,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CircleXIcon,
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
RadarIcon,
|
||||
TriangleAlertIcon
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "./Badge";
|
||||
|
||||
/**
|
||||
* Badges act as an indicator that can optionally be made interactable.
|
||||
* You can place text and icons inside a badge.
|
||||
* Badges are often used for the indication of a status, state or scope.
|
||||
*/
|
||||
const meta = {
|
||||
title: "Generic/Badge",
|
||||
component: Badge,
|
||||
parameters: {
|
||||
layout: "centered"
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["neutral", "success", "info", "warning", "danger", "project", "org", "sub-org"]
|
||||
},
|
||||
isTruncatable: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
},
|
||||
asChild: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
},
|
||||
children: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
}
|
||||
},
|
||||
args: { children: "Badge", isTruncatable: false }
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Neutral: Story = {
|
||||
name: "Variant: Neutral",
|
||||
args: {
|
||||
variant: "neutral",
|
||||
children: (
|
||||
<>
|
||||
<BanIcon />
|
||||
Disabled
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating neutral or disabled states."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
name: "Variant: Success",
|
||||
args: {
|
||||
variant: "success",
|
||||
children: (
|
||||
<>
|
||||
<CheckIcon />
|
||||
Success
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating successful or healthy states."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
name: "Variant: Info",
|
||||
args: {
|
||||
variant: "info",
|
||||
children: (
|
||||
<>
|
||||
<InfoIcon />
|
||||
Info
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use this variant when indicating informational states or linking to external references."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
name: "Variant: Warning",
|
||||
args: {
|
||||
variant: "warning",
|
||||
children: (
|
||||
<>
|
||||
<TriangleAlertIcon />
|
||||
Warning
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating activity or attention warranting states."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
name: "Variant: Danger",
|
||||
args: {
|
||||
variant: "danger",
|
||||
children: (
|
||||
<>
|
||||
<CircleXIcon />
|
||||
Danger
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating destructive or error states."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Organization: Story = {
|
||||
name: "Variant: Organization",
|
||||
args: {
|
||||
variant: "org",
|
||||
children: (
|
||||
<>
|
||||
<GlobeIcon />
|
||||
Organization
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating organization scope or links."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const SubOrganization: Story = {
|
||||
name: "Variant: Sub-Organization",
|
||||
args: {
|
||||
variant: "sub-org",
|
||||
children: (
|
||||
<>
|
||||
<BoxesIcon />
|
||||
Sub-Organization
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating sub-organization scope or links."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Project: Story = {
|
||||
name: "Variant: Project",
|
||||
args: {
|
||||
variant: "project",
|
||||
children: (
|
||||
<>
|
||||
<BoxIcon />
|
||||
Project
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this variant when indicating project scope or links."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const AsExternalLink: Story = {
|
||||
name: "Example: As External Link",
|
||||
args: {
|
||||
variant: "info",
|
||||
asChild: true,
|
||||
children: (
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://infisical.com/">
|
||||
Link <ExternalLinkIcon />
|
||||
</a>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use the `asChild` prop with an `a` tag to use a badge as an external link."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const AsRouterLink: Story = {
|
||||
name: "Example: As Router Link",
|
||||
args: {
|
||||
variant: "project",
|
||||
asChild: true,
|
||||
children: (
|
||||
<Link to=".">
|
||||
<RadarIcon />
|
||||
Secret Scanning
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use the `asChild` prop with a `Link` component to use a badge as an internal link."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const AsButton: Story = {
|
||||
name: "Example: As Button",
|
||||
args: {
|
||||
variant: "org",
|
||||
asChild: true,
|
||||
children: (
|
||||
<button type="button" onClick={() => console.log("click")}>
|
||||
<GlobeIcon />
|
||||
Organization
|
||||
<ChevronsUpDownIcon />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use the `asChild` prop with a `button` tag to use a badge as a button. Do not use a styled `Button` component."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const IsTruncatable: Story = {
|
||||
name: "Example: isTruncatable",
|
||||
args: {
|
||||
isTruncatable: true,
|
||||
children: (
|
||||
<>
|
||||
<GlobeIcon />
|
||||
<span>Infisical Infrastructure</span>
|
||||
</>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use the `isTruncatable` prop with a `span` tag wrapping the text content to support truncation."
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: (Story) => (
|
||||
<div className="flex w-32">
|
||||
<Story />
|
||||
</div>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "cva";
|
||||
|
||||
import { cn } from "@app/components/v3/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
"select-none items-center rounded-sm px-1.5 py-0.5 text-xs",
|
||||
"gap-x-1 [a&,button&]:cursor-pointer inline-flex",
|
||||
"[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:stroke-[2.25] [&>svg]:size-3",
|
||||
"transition duration-200 ease-in-out"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
isTruncatable: {
|
||||
true: "[&>span,&>p]:truncate min-w-0",
|
||||
false: "w-fit shrink-0 whitespace-nowrap overflow-hidden"
|
||||
},
|
||||
variant: {
|
||||
neutral: "bg-neutral/30 text-neutral [a&,button&]:hover:bg-neutral/40",
|
||||
success: "bg-success/30 text-success [a&,button&]:hover:bg-success/40",
|
||||
info: "bg-info/30 text-info [a&,button&]:hover:bg-info/40",
|
||||
warning: "bg-warning/30 text-warning [a&,button&]:hover:bg-warning/40",
|
||||
danger: "bg-danger/30 text-danger [a&,button&]:hover:bg-danger/40",
|
||||
project: "bg-project/30 text-project [a&,button&]:hover:bg-project/40",
|
||||
org: "bg-org/30 text-org [a&,button&]:hover:bg-org/40",
|
||||
"sub-org": "bg-sub-org/30 text-sub-org [a&,button&]:hover:bg-sub-org/40"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "success"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
type TBadgeProps = VariantProps<typeof badgeVariants> &
|
||||
React.ComponentProps<"span"> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, TBadgeProps>(
|
||||
({ className, variant, asChild = false, isTruncatable = false, ...props }, ref): JSX.Element => {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant, isTruncatable }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants, type TBadgeProps };
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Badge";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Badge";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -62,7 +62,11 @@ export const ROUTE_PATHS = Object.freeze({
|
||||
"/organization/app-connections/$appConnection/oauth/callback",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback"
|
||||
)
|
||||
}
|
||||
},
|
||||
NetworkingPage: setRoute(
|
||||
"/organization/networking",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking"
|
||||
)
|
||||
},
|
||||
SecretManager: {
|
||||
ApprovalPage: setRoute(
|
||||
|
||||
@@ -135,6 +135,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
||||
case GitHubConnectionMethod.App:
|
||||
case GitHubRadarConnectionMethod.App:
|
||||
return { name: "GitHub App", icon: faGithub };
|
||||
case GitHubConnectionMethod.Pat:
|
||||
return { name: "Personal Access Token", icon: faKey };
|
||||
case AzureKeyVaultConnectionMethod.OAuth:
|
||||
case AzureAppConfigurationConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con
|
||||
|
||||
export enum GitHubConnectionMethod {
|
||||
App = "github-app",
|
||||
OAuth = "oauth"
|
||||
OAuth = "oauth",
|
||||
Pat = "pat"
|
||||
}
|
||||
|
||||
export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & (
|
||||
@@ -24,4 +25,12 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub
|
||||
host?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
method: GitHubConnectionMethod.Pat;
|
||||
credentials: {
|
||||
personalAccessToken: string;
|
||||
instanceType?: "cloud" | "server";
|
||||
host?: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
|
||||
apiConfig?: {
|
||||
id: string;
|
||||
autoRenew: boolean;
|
||||
autoRenewDays?: number;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = {
|
||||
};
|
||||
apiConfig?: {
|
||||
autoRenew?: boolean;
|
||||
autoRenewDays?: number;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = {
|
||||
};
|
||||
apiConfig?: {
|
||||
autoRenew?: boolean;
|
||||
autoRenewDays?: number;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations";
|
||||
export {
|
||||
useDeleteCert,
|
||||
useImportCertificate,
|
||||
useRenewCertificate,
|
||||
useRevokeCert,
|
||||
useUpdateRenewalConfig
|
||||
} from "./mutations";
|
||||
export { useGetCert, useGetCertBody } from "./queries";
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
TDeleteCertDTO,
|
||||
TImportCertificateDTO,
|
||||
TImportCertificateResponse,
|
||||
TRevokeCertDTO
|
||||
TRenewCertificateDTO,
|
||||
TRenewCertificateResponse,
|
||||
TRevokeCertDTO,
|
||||
TUpdateRenewalConfigDTO
|
||||
} from "./types";
|
||||
|
||||
export const useDeleteCert = () => {
|
||||
@@ -77,3 +80,57 @@ export const useImportCertificate = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useRenewCertificate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TRenewCertificateResponse, object, TRenewCertificateDTO>({
|
||||
mutationFn: async ({ certificateId }) => {
|
||||
const { data } = await apiRequest.post<TRenewCertificateResponse>(
|
||||
`/api/v3/certificates/${certificateId}/renew`,
|
||||
{}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["certificate-profiles", "list"]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.allProjectCertificates()
|
||||
});
|
||||
if (data.projectId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.forProjectCertificates(data.projectId)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateRenewalConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{ message: string; renewBeforeDays?: number },
|
||||
object,
|
||||
TUpdateRenewalConfigDTO
|
||||
>({
|
||||
mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => {
|
||||
const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>(
|
||||
`/api/v3/certificates/${certificateId}/config`,
|
||||
{ renewBeforeDays, enableAutoRenewal }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.forProjectCertificates(projectSlug)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.allProjectCertificates()
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export type TCertificate = {
|
||||
id: string;
|
||||
caId: string;
|
||||
certificateTemplateId?: string;
|
||||
profileId?: string;
|
||||
status: CertStatus;
|
||||
friendlyName: string;
|
||||
commonName: string;
|
||||
@@ -13,6 +14,12 @@ export type TCertificate = {
|
||||
notAfter: string;
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
renewBeforeDays?: number;
|
||||
renewedBy?: string;
|
||||
renewedFromCertificateId?: string;
|
||||
renewedByCertificateId?: string;
|
||||
renewalError?: string;
|
||||
hasPrivateKey?: boolean;
|
||||
};
|
||||
|
||||
export type TDeleteCertDTO = {
|
||||
@@ -43,3 +50,24 @@ export type TImportCertificateResponse = {
|
||||
privateKey: string;
|
||||
serialNumber: string;
|
||||
};
|
||||
|
||||
export type TRenewCertificateDTO = {
|
||||
certificateId: string;
|
||||
};
|
||||
|
||||
export type TRenewCertificateResponse = {
|
||||
certificate: string;
|
||||
issuingCaCertificate: string;
|
||||
certificateChain: string;
|
||||
privateKey?: string;
|
||||
serialNumber: string;
|
||||
certificateId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TUpdateRenewalConfigDTO = {
|
||||
certificateId: string;
|
||||
renewBeforeDays?: number;
|
||||
enableAutoRenewal?: boolean;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from "./pkiSubscriber";
|
||||
export * from "./projects";
|
||||
export * from "./projectUserAdditionalPrivilege";
|
||||
export * from "./rateLimit";
|
||||
export * from "./relays";
|
||||
export * from "./roles";
|
||||
export * from "./scim";
|
||||
export * from "./secretApproval";
|
||||
|
||||
@@ -68,6 +68,8 @@ export type PlanBillingInfo = {
|
||||
interval: "month" | "year";
|
||||
intervalCount: number;
|
||||
quantity: number;
|
||||
users: number;
|
||||
identities: number;
|
||||
};
|
||||
|
||||
export type Invoice = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__";
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum PamResourceType {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql",
|
||||
RDP = "rdp",
|
||||
SSH = "ssh",
|
||||
Kubernetes = "kubernetes"
|
||||
|
||||
@@ -5,6 +5,7 @@ export const PAM_RESOURCE_TYPE_MAP: Record<
|
||||
{ name: string; image: string; size?: number }
|
||||
> = {
|
||||
[PamResourceType.Postgres]: { name: "PostgreSQL", image: "Postgres.png" },
|
||||
[PamResourceType.MySQL]: { name: "MySQL", image: "MySql.png" },
|
||||
[PamResourceType.RDP]: { name: "RDP", image: "RDP.png" },
|
||||
[PamResourceType.SSH]: { name: "SSH", image: "SSH.png" },
|
||||
[PamResourceType.Kubernetes]: { name: "Kubernetes", image: "Kubernetes.png" }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TPamResourceOption } from "./types/resource-options";
|
||||
import { PamResourceType } from "./enums";
|
||||
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
|
||||
|
||||
export const pamKeys = {
|
||||
@@ -12,6 +13,12 @@ export const pamKeys = {
|
||||
session: () => [...pamKeys.all, "session"] as const,
|
||||
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
|
||||
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
|
||||
getResource: (resourceType: string, resourceId: string) => [
|
||||
...pamKeys.resource(),
|
||||
"get",
|
||||
resourceType,
|
||||
resourceId
|
||||
],
|
||||
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
|
||||
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
|
||||
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
|
||||
@@ -68,6 +75,28 @@ export const useListPamResources = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetPamResourceById = (
|
||||
resourceType?: PamResourceType,
|
||||
resourceId?: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<TPamResource, unknown, TPamResource, ReturnType<typeof pamKeys.getResource>>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.getResource(resourceType || "", resourceId || ""),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ resource: TPamResource }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}`
|
||||
);
|
||||
|
||||
return data.resource;
|
||||
},
|
||||
enabled: !!resourceId && !!resourceType && (options?.enabled ?? true),
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export const useListPamAccounts = (
|
||||
projectId: string,
|
||||
|
||||
@@ -9,9 +9,13 @@ export interface TBasePamAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
resourceType: PamResourceType;
|
||||
rotationCredentialsConfigured: boolean;
|
||||
};
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
lastRotatedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { PamResourceType, PamSessionStatus } from "../enums";
|
||||
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
||||
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
|
||||
|
||||
export * from "./postgres-resource";
|
||||
export * from "./mysql-resource";
|
||||
|
||||
export type TPamResource = TPostgresResource;
|
||||
export type TPamResource = TPostgresResource | TMySQLResource;
|
||||
|
||||
export type TPamAccount = TPostgresAccount;
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount;
|
||||
|
||||
export type TPamFolder = {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource";
|
||||
import { TBasePamAccount } from "./base-account";
|
||||
import { TBasePamResource } from "./base-resource";
|
||||
|
||||
// Resources
|
||||
export type TMySQLResource = TBasePamResource & { resourceType: PamResourceType.MySQL } & {
|
||||
connectionDetails: TBaseSqlConnectionDetails;
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export type TMySQLAccount = TBasePamAccount & {
|
||||
credentials: TBaseSqlCredentials;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource";
|
||||
// Resources
|
||||
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
|
||||
connectionDetails: TBaseSqlConnectionDetails;
|
||||
rotationAccountCredentials?: TBaseSqlCredentials | null;
|
||||
};
|
||||
|
||||
// Accounts
|
||||
|
||||
+16
-2
@@ -37,12 +37,26 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
/*legacy color schema */
|
||||
/* Fonts */
|
||||
--font-inter: "Inter", sans-serif;
|
||||
--max-width-8xl: 88rem; /* 1408px */
|
||||
|
||||
/* Colors v2 */
|
||||
--color-background: #19191c;
|
||||
--color-foreground: white;
|
||||
--color-success: #2ecc71;
|
||||
--color-info: #34c2db;
|
||||
--color-warning: #f1c40f;
|
||||
--color-danger: #e74c3c;
|
||||
--color-org: #30B3FF;
|
||||
--color-sub-org: #96ff59;
|
||||
--color-project: #e0ed34;
|
||||
--color-neutral: #adaeb0;
|
||||
|
||||
/*legacy color schema */
|
||||
--color-org-v1: #30B3FF;
|
||||
--color-namespace-v1: #96ff59;
|
||||
--max-width-8xl: 88rem; /* 1408px */
|
||||
|
||||
/* Primary */
|
||||
--color-primary-50: #fffff5;
|
||||
--color-primary-100: #fcfce8;
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SignupInvitePage = () => {
|
||||
|
||||
// Step 4 of the sign up process (download the emergency kit pdf)
|
||||
const stepConfirmEmail = (
|
||||
<div className="mx-1 mb-36 flex h-7/12 w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
|
||||
<div className="mx-1 mt-14 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
|
||||
<p className="mb-2 flex justify-center text-center text-4xl font-medium text-primary-100">
|
||||
Confirm your email
|
||||
</p>
|
||||
@@ -179,7 +179,7 @@ export const SignupInvitePage = () => {
|
||||
|
||||
// Because this is the invite signup - we directly go to the last step of signup (email is already verified)
|
||||
const main = (
|
||||
<div className="mx-auto mb-32 h-7/12 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
|
||||
<div className="mx-auto mt-14 mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
|
||||
<p className="mx-8 mb-6 flex justify-center bg-linear-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
|
||||
Almost there!
|
||||
</p>
|
||||
|
||||
+16
-14
@@ -111,6 +111,7 @@ type TCertificateDetails = {
|
||||
|
||||
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
|
||||
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
|
||||
const [shouldShowSubjectSection, setShouldShowSubjectSection] = useState(true);
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const inputSerialNumber =
|
||||
@@ -127,19 +128,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
|
||||
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 formResolver = useMemo(() => {
|
||||
return zodResolver(createSchema(shouldShowSubjectSection));
|
||||
}, [shouldShowSubjectSection]);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -150,7 +141,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
formState,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createSchema((templateData?.subject?.length || 0) > 0)),
|
||||
resolver: formResolver,
|
||||
defaultValues: {
|
||||
profileId: profileId || "",
|
||||
subjectAttributes: [],
|
||||
@@ -169,6 +160,16 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
[profilesData?.certificateProfiles, actualSelectedProfileId]
|
||||
);
|
||||
|
||||
const { data: templateData } = useGetCertificateTemplateV2ById({
|
||||
templateId: actualSelectedProfile?.certificateTemplateId || ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (templateData !== undefined) {
|
||||
setShouldShowSubjectSection((templateData?.subject?.length || 0) > 0);
|
||||
}
|
||||
}, [templateData]);
|
||||
|
||||
const {
|
||||
constraints,
|
||||
filteredKeyUsages,
|
||||
@@ -186,6 +187,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
|
||||
const resetAllState = useCallback(() => {
|
||||
setCertificateDetails(null);
|
||||
setShouldShowSubjectSection(true);
|
||||
resetConstraints();
|
||||
reset();
|
||||
}, [reset, resetConstraints]);
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useMemo } 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, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { useGetCertificateProfileById } from "@app/hooks/api/certificateProfiles";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const DEFAULT_RENEWAL_BEFORE_DAYS = 20;
|
||||
const MIN_RENEWAL_BEFORE_DAYS = 1;
|
||||
const MAX_RENEWAL_BEFORE_DAYS = 30;
|
||||
|
||||
const createFormSchema = (ttlDays: number, notAfter: string) =>
|
||||
z.object({
|
||||
renewBeforeDays: z
|
||||
.number()
|
||||
.min(MIN_RENEWAL_BEFORE_DAYS, `Renewal days must be at least ${MIN_RENEWAL_BEFORE_DAYS}`)
|
||||
.max(MAX_RENEWAL_BEFORE_DAYS, `Renewal days cannot exceed ${MAX_RENEWAL_BEFORE_DAYS}`)
|
||||
.refine(
|
||||
(value) => value < ttlDays,
|
||||
(value) => ({
|
||||
message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)`
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(value) => {
|
||||
const expiryDate = new Date(notAfter);
|
||||
const renewalDate = new Date(expiryDate.getTime() - value * 24 * 60 * 60 * 1000);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
return renewalDate >= tomorrow;
|
||||
},
|
||||
() => ({
|
||||
message: "Renewals can only be scheduled from tomorrow onwards."
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["manageRenewal"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const RenewalConfigForm = ({
|
||||
control,
|
||||
errors,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
buttonText,
|
||||
onCancel
|
||||
}: {
|
||||
control: any;
|
||||
errors: { renewBeforeDays?: { message?: string } };
|
||||
onSubmit: (e?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
buttonText: string;
|
||||
onCancel: () => void;
|
||||
}) => (
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormControl
|
||||
label="Auto-renew days before expiry"
|
||||
isError={Boolean(errors.renewBeforeDays)}
|
||||
errorText={errors.renewBeforeDays?.message}
|
||||
className="mb-6"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="renewBeforeDays"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={MIN_RENEWAL_BEFORE_DAYS}
|
||||
max={MAX_RENEWAL_BEFORE_DAYS}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(value);
|
||||
}}
|
||||
placeholder="Enter days before expiration"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" colorSchema="secondary" variant="plain" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" colorSchema="primary" isLoading={isLoading} isDisabled={isLoading}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } =
|
||||
useUpdateRenewalConfig();
|
||||
|
||||
const certificateData = popUp.manageRenewal.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
renewBeforeDays?: number;
|
||||
ttlDays?: number;
|
||||
notAfter: string;
|
||||
renewalError?: string;
|
||||
renewedFromCertificateId?: string;
|
||||
renewedByCertificateId?: string;
|
||||
};
|
||||
|
||||
const { data: profileData } = useGetCertificateProfileById({
|
||||
profileId: certificateData?.profileId || ""
|
||||
});
|
||||
|
||||
const defaultRenewalDays = useMemo(() => {
|
||||
if (certificateData?.renewBeforeDays) {
|
||||
return certificateData.renewBeforeDays;
|
||||
}
|
||||
if (profileData?.apiConfig?.renewBeforeDays) {
|
||||
return profileData.apiConfig.renewBeforeDays;
|
||||
}
|
||||
return DEFAULT_RENEWAL_BEFORE_DAYS;
|
||||
}, [certificateData?.renewBeforeDays, profileData?.apiConfig?.renewBeforeDays]);
|
||||
|
||||
const isAutoRenewalEnabled = Boolean(
|
||||
certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0
|
||||
);
|
||||
|
||||
const hasRenewalError = Boolean(certificateData?.renewalError);
|
||||
|
||||
const formSchema = createFormSchema(
|
||||
certificateData?.ttlDays || 365,
|
||||
certificateData?.notAfter || ""
|
||||
);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
renewBeforeDays: defaultRenewalDays
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (popUp.manageRenewal.isOpen) {
|
||||
reset({
|
||||
renewBeforeDays: defaultRenewalDays
|
||||
});
|
||||
}
|
||||
}, [popUp.manageRenewal.isOpen, defaultRenewalDays, reset]);
|
||||
|
||||
const onUpdateRenewal = async (data: FormData) => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Unable to update auto-renewal: Project not found. Please refresh the page and try again.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId: certificateData.certificateId,
|
||||
renewBeforeDays: data.renewBeforeDays,
|
||||
projectSlug: currentProject.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: isAutoRenewalEnabled
|
||||
? "Auto-renewal configuration updated successfully"
|
||||
: "Auto-renewal enabled successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("manageRenewal", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: isAutoRenewalEnabled
|
||||
? "Failed to update auto-renewal configuration. Please check your inputs and try again."
|
||||
: "Failed to enable auto-renewal. Please check your inputs and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (hasRenewalError) {
|
||||
return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`;
|
||||
}
|
||||
if (isAutoRenewalEnabled) {
|
||||
return `Manage Auto-Renewal for ${certificateData?.commonName || ""}`;
|
||||
}
|
||||
return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`;
|
||||
};
|
||||
|
||||
if (!certificateData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.manageRenewal?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("manageRenewal", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={getModalTitle()}>
|
||||
{hasRenewalError && (
|
||||
<div className="mb-6 rounded-md border border-red-600 bg-red-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-600">
|
||||
<span className="text-xs font-bold text-white">!</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-red-400">Automatic Renewal Failed</h3>
|
||||
<p className="mt-1 text-sm text-red-300">
|
||||
The last automatic renewal attempt failed: {certificateData.renewalError}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-red-300">
|
||||
You can reconfigure auto-renewal below or disable it completely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!isAutoRenewalEnabled || hasRenewalError) && (
|
||||
<RenewalConfigForm
|
||||
control={control}
|
||||
errors={errors}
|
||||
onSubmit={handleSubmit(onUpdateRenewal)}
|
||||
isLoading={isUpdatingConfig}
|
||||
buttonText={isAutoRenewalEnabled ? "Update Configuration" : "Enable Auto-Renewal"}
|
||||
onCancel={() => handlePopUpToggle("manageRenewal", false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAutoRenewalEnabled && !hasRenewalError && (
|
||||
<RenewalConfigForm
|
||||
control={control}
|
||||
errors={errors}
|
||||
onSubmit={handleSubmit(onUpdateRenewal)}
|
||||
isLoading={isUpdatingConfig}
|
||||
buttonText="Update Configuration"
|
||||
onCancel={() => handlePopUpToggle("manageRenewal", false)}
|
||||
/>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
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, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const createFormSchema = (ttlDays: number) =>
|
||||
z.object({
|
||||
renewBeforeDays: z
|
||||
.number()
|
||||
.min(1, "Renewal days must be at least 1")
|
||||
.max(365, "Renewal days cannot exceed 365")
|
||||
.refine(
|
||||
(value) => value < ttlDays,
|
||||
(value) => ({
|
||||
message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)`
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["configureRenewal"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["configureRenewal"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
|
||||
|
||||
const certificateData = popUp.configureRenewal.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
renewBeforeDays?: number;
|
||||
ttlDays: number;
|
||||
};
|
||||
|
||||
const formSchema = createFormSchema(certificateData.ttlDays);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
renewBeforeDays: certificateData?.renewBeforeDays || 1
|
||||
}
|
||||
});
|
||||
|
||||
const renewBeforeDays = watch("renewBeforeDays");
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Project not found",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId: certificateData.certificateId,
|
||||
renewBeforeDays: data.renewBeforeDays,
|
||||
projectSlug: currentProject.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated auto-renewal configuration",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("configureRenewal", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update auto-renewal configuration",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.configureRenewal?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("configureRenewal", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`Configure Auto-Renewal: ${certificateData?.commonName || ""}`}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Configure when this certificate should be automatically renewed. The certificate will
|
||||
be renewed when it has the specified number of days remaining before expiration.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 rounded border bg-mineshaft-800 p-3">
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
<strong>Certificate TTL:</strong> {certificateData?.ttlDays} days
|
||||
</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
<strong>Current Setting:</strong>{" "}
|
||||
{certificateData?.renewBeforeDays
|
||||
? `${certificateData.renewBeforeDays} days before expiration`
|
||||
: "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="renewBeforeDays"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Renew Before Days"
|
||||
isError={Boolean(errors.renewBeforeDays)}
|
||||
errorText={errors.renewBeforeDays?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
max={certificateData?.ttlDays ? certificateData.ttlDays - 1 : undefined}
|
||||
placeholder="Enter days before expiration"
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(Number.isNaN(value) ? 0 : value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{renewBeforeDays && certificateData?.ttlDays && (
|
||||
<div className="mt-2 rounded bg-primary-900/20 p-2">
|
||||
<p className="text-sm text-primary-300">
|
||||
{renewBeforeDays >= certificateData.ttlDays
|
||||
? "⚠️ Renewal days must be less than certificate TTL"
|
||||
: `✓ Certificate will be renewed ${renewBeforeDays} days before expiration`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || renewBeforeDays >= (certificateData?.ttlDays || 0)}
|
||||
>
|
||||
Update Configuration
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("configureRenewal", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["disableRenewal"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["disableRenewal"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
|
||||
|
||||
const certificateData = popUp.disableRenewal.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
};
|
||||
|
||||
const onDisableConfirm = async () => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Project not found",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId: certificateData.certificateId,
|
||||
projectSlug: currentProject.slug,
|
||||
enableAutoRenewal: false
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully disabled auto-renewal",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("disableRenewal", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to disable auto-renewal",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.disableRenewal?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("disableRenewal", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`Disable Auto-Renewal: ${certificateData?.commonName || ""}`}>
|
||||
<div className="mb-4">
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
Are you sure you want to disable auto-renewal for this certificate?
|
||||
</p>
|
||||
<div className="rounded border border-yellow-700/50 bg-yellow-900/20 p-3">
|
||||
<p className="text-sm text-yellow-300">
|
||||
<strong>Warning:</strong> Once disabled, this certificate will not be automatically
|
||||
renewed and may expire without notice. You can re-enable auto-renewal at any time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
onClick={onDisableConfirm}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Disable Auto-Renewal
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("disableRenewal", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useRenewCertificate } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["renewCertificate"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["renewCertificate"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { mutateAsync: renewCertificate, isPending: isRenewing } = useRenewCertificate();
|
||||
|
||||
const onRenewConfirm = async () => {
|
||||
try {
|
||||
const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
|
||||
|
||||
await renewCertificate({
|
||||
certificateId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Certificate renewed successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("renewCertificate", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const certificateData = popUp.renewCertificate.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.renewCertificate?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("renewCertificate", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`Renew Certificate: ${certificateData?.commonName || ""}`}>
|
||||
<div className="mb-6">
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Are you sure you want to renew this certificate now?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onRenewConfirm}
|
||||
colorSchema="primary"
|
||||
isLoading={isRenewing}
|
||||
isDisabled={isRenewing}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRedo} className="mr-2" />
|
||||
Renew Now
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("renewCertificate", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { CertificateCertModal } from "./CertificateCertModal";
|
||||
import { CertificateImportModal } from "./CertificateImportModal";
|
||||
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
|
||||
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
|
||||
import { CertificateModal } from "./CertificateModal";
|
||||
import { CertificateRenewalModal } from "./CertificateRenewalModal";
|
||||
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
||||
import { CertificatesTable } from "./CertificatesTable";
|
||||
|
||||
@@ -33,7 +35,9 @@ export const CertificatesSection = () => {
|
||||
"certificateImport",
|
||||
"certificateCert",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate"
|
||||
"revokeCertificate",
|
||||
"manageRenewal",
|
||||
"renewCertificate"
|
||||
] as const);
|
||||
|
||||
const onRemoveCertificateSubmit = async (serialNumber: string) => {
|
||||
@@ -98,6 +102,8 @@ export const CertificatesSection = () => {
|
||||
)}
|
||||
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteCertificate.isOpen}
|
||||
|
||||
+342
-27
@@ -5,12 +5,15 @@ import {
|
||||
faEllipsis,
|
||||
faEye,
|
||||
faFileExport,
|
||||
faQuestionCircle,
|
||||
faRedo,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
@@ -33,25 +36,136 @@ import {
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub,
|
||||
useProject
|
||||
useProject,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useListWorkspaceCertificates } from "@app/hooks/api";
|
||||
import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { caSupportsCapability } from "@app/hooks/api/ca/constants";
|
||||
import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import { CertStatus } from "@app/hooks/api/certificates/enums";
|
||||
import { TCertificate } from "@app/hooks/api/certificates/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
|
||||
|
||||
const isExpiringWithinOneDay = (notAfter: string): boolean => {
|
||||
const expiryDate = new Date(notAfter);
|
||||
const now = new Date();
|
||||
const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
return expiryDate <= oneDayFromNow;
|
||||
};
|
||||
|
||||
const getAutoRenewalInfo = (certificate: TCertificate) => {
|
||||
if (certificate.renewedByCertificateId) {
|
||||
return { text: "Renewed", variant: "instance" as const };
|
||||
}
|
||||
|
||||
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||
const hasNoProfile = !certificate.profileId;
|
||||
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||
|
||||
if (isRevoked) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal is not available for revoked certificates"
|
||||
};
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal is not available for expired certificates"
|
||||
};
|
||||
}
|
||||
|
||||
if (hasNoProfile) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal requires a certificate profile"
|
||||
};
|
||||
}
|
||||
|
||||
if (certificate.hasPrivateKey === false) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal is not available for certificates with externally generated private keys"
|
||||
};
|
||||
}
|
||||
|
||||
if (isExpiringWithinDay) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Auto-renewal is not available for certificates expiring within 24 hours"
|
||||
};
|
||||
}
|
||||
|
||||
if (certificate.renewalError) {
|
||||
return {
|
||||
text: "Failed",
|
||||
variant: "danger" as const,
|
||||
tooltip: certificate.renewalError
|
||||
};
|
||||
}
|
||||
|
||||
if (!certificate.renewBeforeDays) {
|
||||
return { text: "Auto-Renewal Disabled", variant: "primary" as const };
|
||||
}
|
||||
|
||||
const notAfterDate = new Date(certificate.notAfter);
|
||||
const renewalDate = new Date(
|
||||
notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
if (renewalDate <= now) {
|
||||
return { text: "Due Now", variant: "danger" as const };
|
||||
}
|
||||
|
||||
const daysUntilRenewal = Math.floor(
|
||||
(renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
if (daysUntilRenewal === 0) {
|
||||
return { text: "Renews today", variant: "primary" as const };
|
||||
}
|
||||
|
||||
if (daysUntilRenewal <= 7) {
|
||||
return { text: `Renews in ${daysUntilRenewal}d`, variant: "primary" as const };
|
||||
}
|
||||
|
||||
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"]
|
||||
[
|
||||
"certificate",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"certificateCert",
|
||||
"manageRenewal",
|
||||
"renewCertificate"
|
||||
]
|
||||
>,
|
||||
data?: {
|
||||
serialNumber?: string;
|
||||
commonName?: string;
|
||||
certificateId?: string;
|
||||
profileId?: string;
|
||||
renewBeforeDays?: number;
|
||||
ttlDays?: number;
|
||||
notAfter?: string;
|
||||
renewalError?: string;
|
||||
renewedFromCertificateId?: string;
|
||||
renewedByCertificateId?: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
@@ -61,6 +175,7 @@ const PER_PAGE_INIT = 25;
|
||||
export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { currentProject } = useProject();
|
||||
const { data, isPending } = useListWorkspaceCertificates({
|
||||
@@ -69,10 +184,11 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
limit: perPage
|
||||
});
|
||||
|
||||
// Fetch CA data to determine capabilities
|
||||
const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
|
||||
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
|
||||
|
||||
const { data: caData } = useListCasByProjectId(currentProject?.id ?? "");
|
||||
|
||||
// Create mapping from caId to CA type for capability checking
|
||||
const caCapabilityMap = useMemo(() => {
|
||||
if (!caData) return {};
|
||||
|
||||
@@ -83,6 +199,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
return map;
|
||||
}, [caData]);
|
||||
|
||||
const handleDisableAutoRenewal = async (certificateId: string, commonName: string) => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Unable to disable auto-renewal: Project not found. Please refresh the page and try again.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId,
|
||||
projectSlug: currentProject.slug,
|
||||
enableAutoRenewal: false
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Auto-renewal disabled for ${commonName}`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to disable auto-renewal. Please try again or contact support if the issue persists.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -92,14 +237,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
<Th>Status</Th>
|
||||
<Th>Not Before</Th>
|
||||
<Th>Not After</Th>
|
||||
<Th>Renewal Status</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
|
||||
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
|
||||
{!isPending &&
|
||||
data?.certificates.map((certificate) => {
|
||||
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
|
||||
const autoRenewalInfo = getAutoRenewalInfo(certificate);
|
||||
|
||||
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||
const hasFailed = Boolean(certificate.renewalError);
|
||||
const isAutoRenewalEnabled = Boolean(
|
||||
certificate.renewBeforeDays && certificate.renewBeforeDays > 0
|
||||
);
|
||||
return (
|
||||
<Tr className="h-10" key={`certificate-${certificate.id}`}>
|
||||
<Td>{certificate.commonName}</Td>
|
||||
@@ -120,6 +275,25 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
|
||||
: "-"}
|
||||
</Td>
|
||||
<Td>
|
||||
{autoRenewalInfo &&
|
||||
(autoRenewalInfo.tooltip ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={autoRenewalInfo.variant}>
|
||||
{autoRenewalInfo.text}
|
||||
<Tooltip content={autoRenewalInfo.tooltip}>
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
className="ml-1 cursor-help text-red-400 hover:text-red-300"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge>
|
||||
))}
|
||||
</Td>
|
||||
<Td className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
@@ -151,31 +325,172 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Read}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificate", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEye} />}
|
||||
{isLegacyTemplatesEnabled && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Read}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificate", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEye} />}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
{/* Manage auto renewal option - not shown for failed renewals */}
|
||||
{(() => {
|
||||
const canManageRenewal =
|
||||
certificate.profileId &&
|
||||
certificate.hasPrivateKey !== false &&
|
||||
!certificate.renewedByCertificateId &&
|
||||
!isRevoked &&
|
||||
!isExpired &&
|
||||
!hasFailed &&
|
||||
!isExpiringWithinDay;
|
||||
|
||||
if (!canManageRenewal) return null;
|
||||
|
||||
return (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Edit}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
const notAfterDate = new Date(certificate.notAfter);
|
||||
const notBeforeDate = certificate.notBefore
|
||||
? new Date(certificate.notBefore)
|
||||
: new Date(
|
||||
notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const ttlDays = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(notAfterDate.getTime() - notBeforeDate.getTime()) /
|
||||
(24 * 60 * 60 * 1000)
|
||||
)
|
||||
);
|
||||
handlePopUpOpen("manageRenewal", {
|
||||
certificateId: certificate.id,
|
||||
commonName: certificate.commonName,
|
||||
profileId: certificate.profileId,
|
||||
renewBeforeDays: certificate.renewBeforeDays,
|
||||
ttlDays,
|
||||
notAfter: certificate.notAfter,
|
||||
renewalError: certificate.renewalError,
|
||||
renewedFromCertificateId:
|
||||
certificate.renewedFromCertificateId,
|
||||
renewedByCertificateId: certificate.renewedByCertificateId
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faRedo} />}
|
||||
>
|
||||
{isAutoRenewalEnabled
|
||||
? "Manage auto renewal"
|
||||
: "Enable auto renewal"}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}}
|
||||
</ProjectPermissionCan>
|
||||
);
|
||||
})()}
|
||||
{/* Disable auto renewal option - only shown when auto renewal is active */}
|
||||
{(() => {
|
||||
const canDisableRenewal =
|
||||
certificate.profileId &&
|
||||
certificate.hasPrivateKey !== false &&
|
||||
!certificate.renewedByCertificateId &&
|
||||
!isRevoked &&
|
||||
!isExpired &&
|
||||
!isExpiringWithinDay &&
|
||||
isAutoRenewalEnabled;
|
||||
|
||||
if (!canDisableRenewal) return null;
|
||||
|
||||
return (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Edit}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
await handleDisableAutoRenewal(
|
||||
certificate.id,
|
||||
certificate.commonName
|
||||
);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faBan} />}
|
||||
>
|
||||
Disable auto renewal
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
);
|
||||
})()}
|
||||
{/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */}
|
||||
{(() => {
|
||||
const canRenew =
|
||||
certificate.profileId &&
|
||||
certificate.hasPrivateKey !== false &&
|
||||
!certificate.renewedByCertificateId &&
|
||||
!isRevoked &&
|
||||
!isExpired;
|
||||
|
||||
if (!canRenew) return null;
|
||||
|
||||
return (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Edit}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("renewCertificate", {
|
||||
certificateId: certificate.id,
|
||||
commonName: certificate.commonName
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faRedo} />}
|
||||
>
|
||||
Renew Now
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
);
|
||||
})()}
|
||||
{/* Only show revoke button if CA supports revocation */}
|
||||
{(() => {
|
||||
const caType = caCapabilityMap[certificate.caId];
|
||||
// If caId not found in map, assume CA supports revocation to avoid hiding revoke option
|
||||
const supportsRevocation =
|
||||
!caType ||
|
||||
caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES);
|
||||
|
||||
+21
-1
@@ -11,6 +11,26 @@ import {
|
||||
mapTemplateSignatureAlgorithmToApi
|
||||
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
|
||||
|
||||
const convertTemplateTtlToCertificateTtl = (templateTtl: string): string => {
|
||||
const match = templateTtl.match(/^(\d+)([dmyh])$/);
|
||||
if (!match) return templateTtl;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const numValue = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "m":
|
||||
return `${numValue * 30}d`;
|
||||
case "y":
|
||||
return `${numValue * 365}d`;
|
||||
case "d":
|
||||
case "h":
|
||||
return templateTtl;
|
||||
default:
|
||||
return templateTtl;
|
||||
}
|
||||
};
|
||||
|
||||
export type TemplateConstraints = {
|
||||
allowedKeyUsages: string[];
|
||||
allowedExtendedKeyUsages: string[];
|
||||
@@ -118,7 +138,7 @@ export const useCertificateTemplate = (
|
||||
|
||||
// Set TTL if available
|
||||
if (templateData.validity?.max) {
|
||||
setValue("ttl", templateData.validity.max);
|
||||
setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max));
|
||||
}
|
||||
|
||||
// Handle SAN types
|
||||
|
||||
+25
-13
@@ -1,5 +1,7 @@
|
||||
import { useEffect } 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";
|
||||
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
@@ -67,7 +70,7 @@ const createSchema = z
|
||||
apiConfig: z
|
||||
.object({
|
||||
autoRenew: z.boolean().optional(),
|
||||
autoRenewDays: z.number().min(1).max(365).optional()
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -115,7 +118,7 @@ const editSchema = z
|
||||
apiConfig: z
|
||||
.object({
|
||||
autoRenew: z.boolean().optional(),
|
||||
autoRenewDays: z.number().min(1).max(365).optional()
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
profile.enrollmentType === "api"
|
||||
? {
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
certificateTemplateId: "",
|
||||
apiConfig: {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
renewBeforeDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
profile.enrollmentType === "api"
|
||||
? {
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
@@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
} else {
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
renewBeforeDays: 30
|
||||
});
|
||||
setValue("estConfig", undefined);
|
||||
}
|
||||
@@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
setValue("estConfig", undefined);
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
renewBeforeDays: 30
|
||||
});
|
||||
}
|
||||
onChange(value);
|
||||
@@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
|
||||
Enable Auto-Renewal By Default
|
||||
</Checkbox>
|
||||
<Tooltip content="If enabled, certificates issued against this profile will auto-renew at specified days before expiration.">
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
className="cursor-help text-mineshaft-400 hover:text-mineshaft-300"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
<div className="mb-4 space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="apiConfig.autoRenewDays"
|
||||
name="apiConfig.renewBeforeDays"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Auto-Renewal Days"
|
||||
label="Auto-Renewal Days Before Expiration"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
|
||||
+6
-2
@@ -96,7 +96,7 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => {
|
||||
case AppConnection.AWS:
|
||||
return <AwsConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.GitHub:
|
||||
return <GitHubConnectionForm projectId={projectId} />;
|
||||
return <GitHubConnectionForm projectId={projectId} onSubmit={onSubmit} />;
|
||||
case AppConnection.GitHubRadar:
|
||||
return <GitHubRadarConnectionForm projectId={projectId} />;
|
||||
case AppConnection.GCP:
|
||||
@@ -213,7 +213,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
return <AwsConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
|
||||
case AppConnection.GitHub:
|
||||
return (
|
||||
<GitHubConnectionForm appConnection={appConnection} projectId={appConnection.projectId} />
|
||||
<GitHubConnectionForm
|
||||
appConnection={appConnection}
|
||||
projectId={appConnection.projectId}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case AppConnection.GitHubRadar:
|
||||
return (
|
||||
|
||||
+85
-18
@@ -16,6 +16,7 @@ import {
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
@@ -48,28 +49,58 @@ import {
|
||||
type Props = {
|
||||
appConnection?: TGitHubConnection;
|
||||
projectId: string | undefined | null;
|
||||
onSubmit: (formData: PatSchemaForm) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAppConnectionFieldsSchema.extend({
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.GitHub),
|
||||
method: z.nativeEnum(GitHubConnectionMethod),
|
||||
credentials: z
|
||||
.union([
|
||||
z.object({
|
||||
instanceType: z.literal("cloud").optional(),
|
||||
host: z.string().optional()
|
||||
}),
|
||||
z.object({
|
||||
instanceType: z.literal("server"),
|
||||
host: z.string().min(1, "Required")
|
||||
})
|
||||
])
|
||||
.optional()
|
||||
method: z.nativeEnum(GitHubConnectionMethod)
|
||||
});
|
||||
|
||||
const baseCredentialsSchema = z.union([
|
||||
z.object({
|
||||
instanceType: z.literal("server"),
|
||||
host: z.string().min(1, "Host is required for server instance type")
|
||||
}),
|
||||
z.object({
|
||||
instanceType: z.literal("cloud").optional(),
|
||||
host: z.string().optional()
|
||||
})
|
||||
]);
|
||||
|
||||
const appSchema = rootSchema.extend({
|
||||
method: z.literal(GitHubConnectionMethod.App),
|
||||
credentials: baseCredentialsSchema
|
||||
});
|
||||
|
||||
const oauthSchema = rootSchema.extend({
|
||||
method: z.literal(GitHubConnectionMethod.OAuth),
|
||||
credentials: baseCredentialsSchema
|
||||
});
|
||||
|
||||
const patSchema = rootSchema.extend({
|
||||
method: z.literal(GitHubConnectionMethod.Pat),
|
||||
credentials: z.union([
|
||||
z.object({
|
||||
instanceType: z.literal("server"),
|
||||
host: z.string().min(1, "Host is required for server instance type"),
|
||||
personalAccessToken: z.string().min(1, "Personal Access Token is required")
|
||||
}),
|
||||
z.object({
|
||||
instanceType: z.literal("cloud").optional(),
|
||||
host: z.string().optional(),
|
||||
personalAccessToken: z.string().min(1, "Personal Access Token is required")
|
||||
})
|
||||
])
|
||||
});
|
||||
|
||||
type PatSchemaForm = z.infer<typeof patSchema>;
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [appSchema, oauthSchema, patSchema]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => {
|
||||
export const GitHubConnectionForm = ({ appConnection, projectId, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
@@ -106,7 +137,12 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => {
|
||||
|
||||
const returnUrl = useGetAppConnectionOauthReturnUrl();
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
const submitHandler = async (formData: FormData) => {
|
||||
if (formData.method === GitHubConnectionMethod.Pat) {
|
||||
await onSubmit(formData);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRedirecting(true);
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
@@ -151,15 +187,26 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => {
|
||||
case GitHubConnectionMethod.App:
|
||||
isMissingConfig = !appClientSlug;
|
||||
break;
|
||||
case GitHubConnectionMethod.Pat:
|
||||
isMissingConfig = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`);
|
||||
}
|
||||
|
||||
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
|
||||
|
||||
const getButtonText = () => {
|
||||
if (selectedMethod === GitHubConnectionMethod.Pat) {
|
||||
return isUpdate ? "Update Connection" : "Create Connection";
|
||||
}
|
||||
|
||||
return isUpdate ? "Reconnect to GitHub" : "Connect to GitHub";
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<Controller
|
||||
name="method"
|
||||
@@ -201,6 +248,26 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{selectedMethod === GitHubConnectionMethod.Pat && (
|
||||
<Controller
|
||||
name="credentials.personalAccessToken"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Personal Access Token"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="enterprise-options" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
@@ -310,7 +377,7 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => {
|
||||
isLoading={isSubmitting || isRedirecting}
|
||||
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
|
||||
>
|
||||
{isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
|
||||
+27
-10
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faArrowUpRightFromSquare, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { Button, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionBillingActions,
|
||||
OrgPermissionSubjects,
|
||||
@@ -27,6 +27,9 @@ export const PreviewSection = () => {
|
||||
const { subscription } = useSubscription(true);
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useGetOrgPlanBillingInfo(currentOrg?.id ?? "");
|
||||
|
||||
const totalAmount = data?.amount ? data.amount * (data.users + data.identities) : 0;
|
||||
|
||||
const getOrgTrialUrl = useGetOrgTrialUrl();
|
||||
const createCustomerPortalSession = useCreateCustomerPortalSession();
|
||||
|
||||
@@ -190,14 +193,28 @@ export const PreviewSection = () => {
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-2 text-gray-400">Price</p>
|
||||
<p className="mb-8 text-2xl font-medium text-mineshaft-50">
|
||||
{subscription.status === "trialing"
|
||||
? "$0.00 / month"
|
||||
: `${formatAmount(data.amount)} / ${data.interval}`}
|
||||
</p>
|
||||
</div>
|
||||
{subscription.slug !== "enterprise" ? (
|
||||
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-2 text-gray-400">Price</p>
|
||||
<p className="mb-8 text-2xl font-medium text-mineshaft-50">
|
||||
{subscription.status === "trialing" ? (
|
||||
"$0.00 / month"
|
||||
) : (
|
||||
<>
|
||||
{formatAmount(totalAmount)} / {data.interval}
|
||||
{(subscription.slug === "pro" || subscription.slug === "pro-annual") && (
|
||||
<Tooltip
|
||||
content={`Total price is based on the number of users and machine identities at ${formatAmount(data.amount)} each. You have ${data.users} ${data.users > 1 ? "users" : "user"} and ${data.identities} ${data.identities > 1 ? "machine identities" : "machine identity"}.`}
|
||||
className="max-w-lg"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="ml-2" size="xs" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-2 text-gray-400">Subscription renews on</p>
|
||||
<p className="mb-8 text-2xl font-medium text-mineshaft-50">
|
||||
|
||||
+18
-2
@@ -8,6 +8,7 @@ import {
|
||||
faEllipsisV,
|
||||
faInfoCircle,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -17,6 +18,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -47,6 +49,7 @@ import { gatewaysQueryKeys, useDeleteGatewayById } from "@app/hooks/api/gateways
|
||||
import { useDeleteGatewayV2ById } from "@app/hooks/api/gateways-v2";
|
||||
|
||||
import { EditGatewayDetailsModal } from "./components/EditGatewayDetailsModal";
|
||||
import { GatewayDeployModal } from "./components/GatewayDeployModal";
|
||||
|
||||
const GatewayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => {
|
||||
const heartbeatDate = heartbeat ? new Date(heartbeat) : null;
|
||||
@@ -73,6 +76,7 @@ export const GatewayTab = withPermission(
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"deployGateway",
|
||||
"deleteGateway",
|
||||
"editDetails"
|
||||
] as const);
|
||||
@@ -101,8 +105,8 @@ export const GatewayTab = withPermission(
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex grow items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Gateways</h3>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/gateways/overview"
|
||||
@@ -118,6 +122,14 @@ export const GatewayTab = withPermission(
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div className="flex grow" />
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("deployGateway")}
|
||||
>
|
||||
Deploy Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-mineshaft-400">
|
||||
@@ -257,6 +269,10 @@ export const GatewayTab = withPermission(
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleDeleteGateway()}
|
||||
/>
|
||||
<GatewayDeployModal
|
||||
isOpen={popUp.deployGateway.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deployGateway", isOpen)}
|
||||
/>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faCopy, faQuestionCircle, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FilterableSelect,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
ModalClose,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useOrgPermission
|
||||
} from "@app/context";
|
||||
import {
|
||||
useAddIdentityTokenAuth,
|
||||
useCreateTokenIdentityTokenAuth,
|
||||
useGetIdentityMembershipOrgs,
|
||||
useGetIdentityTokenAuth,
|
||||
useGetRelays
|
||||
} from "@app/hooks/api";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
import { RelayOption } from "./RelayOption";
|
||||
|
||||
const baseFormSchema = z.object({
|
||||
name: slugSchema({ field: "name" }),
|
||||
instanceDomain: z.string().url("Must be a valid URL").or(z.literal("")),
|
||||
relay: z
|
||||
.object(
|
||||
{
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
},
|
||||
{ required_error: "Relay is required" }
|
||||
)
|
||||
.nullable()
|
||||
.refine((val) => val !== null, { message: "Relay is required" })
|
||||
});
|
||||
|
||||
const formSchemaWithIdentity = baseFormSchema.extend({
|
||||
identity: z
|
||||
.object(
|
||||
{
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
},
|
||||
{ required_error: "Identity is required" }
|
||||
)
|
||||
.nullable()
|
||||
.refine((val) => val !== null, { message: "Identity is required" })
|
||||
});
|
||||
|
||||
const formSchemaWithToken = baseFormSchema.extend({
|
||||
identityToken: z.string().min(1, "Token is required")
|
||||
});
|
||||
|
||||
export const GatewayCliDeploymentMethod = () => {
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port && port !== "80" ? `:${port}` : "";
|
||||
const siteURL = `${protocol}//${hostname}${portSuffix}`;
|
||||
|
||||
const navigate = useNavigate({
|
||||
from: ROUTE_PATHS.Organization.NetworkingPage.path
|
||||
});
|
||||
|
||||
const [autogenerateToken, setAutogenerateToken] = useState(true);
|
||||
const [step, setStep] = useState<"form" | "command">("form");
|
||||
const [name, setName] = useState("");
|
||||
const [instanceDomain, setInstanceDomain] = useState(siteURL);
|
||||
const [relay, setRelay] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
}>(null);
|
||||
const [identity, setIdentity] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
}>(null);
|
||||
const [identityToken, setIdentityToken] = useState("");
|
||||
const [formErrors, setFormErrors] = useState<z.ZodIssue[]>([]);
|
||||
|
||||
const errors = useMemo(() => {
|
||||
const errorMap: Record<string, string | undefined> = {};
|
||||
formErrors.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errorMap[String(issue.path[0])] = issue.message;
|
||||
}
|
||||
});
|
||||
return errorMap;
|
||||
}, [formErrors]);
|
||||
|
||||
const { data: relays, isPending: isRelaysLoading } = useGetRelays();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const organizationId = currentOrg?.id || "";
|
||||
|
||||
const { permission } = useOrgPermission();
|
||||
const canCreateToken = permission.can(
|
||||
OrgPermissionIdentityActions.CreateToken,
|
||||
OrgPermissionSubjects.Identity
|
||||
);
|
||||
|
||||
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
|
||||
useGetIdentityMembershipOrgs({
|
||||
organizationId,
|
||||
limit: 20000
|
||||
});
|
||||
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
|
||||
|
||||
const { mutateAsync: createToken, isPending: isCreatingToken } =
|
||||
useCreateTokenIdentityTokenAuth();
|
||||
const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } =
|
||||
useAddIdentityTokenAuth();
|
||||
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
|
||||
|
||||
const handleGenerateCommand = async () => {
|
||||
setFormErrors([]);
|
||||
|
||||
if (canCreateToken && autogenerateToken) {
|
||||
const validation = formSchemaWithIdentity.safeParse({
|
||||
name,
|
||||
relay,
|
||||
identity,
|
||||
instanceDomain
|
||||
});
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedIdentity = validation.data.identity;
|
||||
|
||||
try {
|
||||
const { data: identityTokenAuth } = await refetch();
|
||||
if (!identityTokenAuth) {
|
||||
await addIdentityTokenAuth({
|
||||
identityId: validatedIdentity.id,
|
||||
organizationId,
|
||||
accessTokenTTL: 2592000,
|
||||
accessTokenMaxTTL: 2592000,
|
||||
accessTokenNumUsesLimit: 0,
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
|
||||
});
|
||||
createNotification({
|
||||
text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.",
|
||||
type: "warning"
|
||||
});
|
||||
}
|
||||
|
||||
const token = await createToken({
|
||||
identityId: validatedIdentity.id,
|
||||
name: `gateway token for ${name} (autogenerated)`
|
||||
});
|
||||
setIdentityToken(token.accessToken);
|
||||
createNotification({
|
||||
text: "Automatically generated a token for the selected identity.",
|
||||
type: "info"
|
||||
});
|
||||
setStep("command");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to generate token for the selected identity",
|
||||
type: "error"
|
||||
});
|
||||
setIdentityToken("");
|
||||
}
|
||||
} else {
|
||||
const validation = formSchemaWithToken.safeParse({
|
||||
name,
|
||||
relay,
|
||||
identityToken,
|
||||
instanceDomain
|
||||
});
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
}
|
||||
setStep("command");
|
||||
}
|
||||
};
|
||||
|
||||
const command = useMemo(() => {
|
||||
const domainFlag = instanceDomain ? ` --domain=${instanceDomain}` : "";
|
||||
return `infisical gateway start --name=${name} --relay=${
|
||||
relay?.name || ""
|
||||
}${domainFlag} --token=${identityToken}`;
|
||||
}, [name, relay, identityToken, instanceDomain]);
|
||||
|
||||
if (step === "command") {
|
||||
return (
|
||||
<>
|
||||
<FormLabel label="CLI Command" />
|
||||
<div className="flex gap-2">
|
||||
<Input value={command} isDisabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(command);
|
||||
createNotification({
|
||||
text: "Command copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<a
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>Install the Infisical CLI</span>
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
<div className="mt-6 flex items-center">
|
||||
<ModalClose asChild>
|
||||
<Button className="mr-4" size="sm" colorSchema="secondary">
|
||||
Done
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormLabel label="Name" tooltipText="The name for your gateway." />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter gateway name..."
|
||||
isError={Boolean(errors.name)}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red">{errors.name}</p>}
|
||||
|
||||
<FormLabel label="Relay" tooltipText="The relay to use with your gateway." className="mt-4" />
|
||||
<FilterableSelect
|
||||
value={relay}
|
||||
onChange={(newValue) => {
|
||||
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, selectedTab: "relays", action: "deploy-relay" })
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRelay(newValue as SingleValue<{ id: string; name: string }>);
|
||||
}}
|
||||
isLoading={isRelaysLoading}
|
||||
options={[
|
||||
{
|
||||
id: "_create",
|
||||
name: "Deploy New Relay"
|
||||
},
|
||||
...(relays || [])
|
||||
]}
|
||||
placeholder="Select relay..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id}
|
||||
components={{ Option: RelayOption }}
|
||||
/>
|
||||
{errors.relay && <p className="mt-1 text-sm text-red">{errors.relay}</p>}
|
||||
|
||||
<FormLabel
|
||||
label="Infisical Instance Host Address"
|
||||
tooltipText="The host address of the infisical instance that's accessible by the gateway."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={instanceDomain}
|
||||
onChange={(e) => setInstanceDomain(e.target.value)}
|
||||
placeholder="https://app.infisical.com"
|
||||
isError={Boolean(errors.instanceDomain)}
|
||||
/>
|
||||
{errors.instanceDomain && <p className="mt-1 text-sm text-red">{errors.instanceDomain}</p>}
|
||||
|
||||
{canCreateToken && autogenerateToken ? (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Identity"
|
||||
tooltipText="The identity that your gateway will use for authentication."
|
||||
className="mt-4"
|
||||
/>
|
||||
<FilterableSelect
|
||||
value={identity}
|
||||
onChange={(e) =>
|
||||
setIdentity(
|
||||
e as SingleValue<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>
|
||||
)
|
||||
}
|
||||
isLoading={isIdentitiesLoading}
|
||||
placeholder="Select identity..."
|
||||
options={identityMembershipOrgs.map((membership) => membership.identity)}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
{errors.identity && <p className="mt-1 text-sm text-red">{errors.identity}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Identity Token"
|
||||
tooltipText="The identity token that your relay will use for authentication."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={identityToken}
|
||||
onChange={(e) => setIdentityToken(e.target.value)}
|
||||
placeholder="Enter identity token..."
|
||||
isError={Boolean(errors.identityToken)}
|
||||
/>
|
||||
{errors.identityToken && <p className="mt-1 text-sm text-red">{errors.identityToken}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCreateToken && (
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
isChecked={autogenerateToken}
|
||||
onCheckedChange={(e) => {
|
||||
setAutogenerateToken(Boolean(e));
|
||||
}}
|
||||
id="autogenerate-token"
|
||||
className="mr-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>Automatically enable token auth and generate a token for identity</span>
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<>
|
||||
Token authentication will be automatically enabled for the selected identity if
|
||||
it isn't already configured. By default, it will be configured to allow all
|
||||
IP addresses with a token TTL of 30 days. You can manage these settings in
|
||||
Access Control.
|
||||
<br />
|
||||
<br />A token will automatically be generated to be used with the CLI command.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="mt-0.5 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
onClick={handleGenerateCommand}
|
||||
isLoading={isCreatingToken || isAddingTokenAuth}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { GatewayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect";
|
||||
|
||||
import { GatewayCliDeploymentMethod } from "./GatewayCliDeploymentMethod";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const GatewayDeploymentInfoMap = {
|
||||
cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod }
|
||||
} as const;
|
||||
|
||||
export type GatewayDeploymentMethod = keyof typeof GatewayDeploymentInfoMap;
|
||||
|
||||
const Content = () => {
|
||||
const [selectedMethod, setSelectedMethod] = useState<null | GatewayDeploymentMethod>(null);
|
||||
|
||||
if (selectedMethod) {
|
||||
const ComponentToRender = GatewayDeploymentInfoMap[selectedMethod]?.component;
|
||||
if (ComponentToRender) {
|
||||
return <ComponentToRender />;
|
||||
}
|
||||
}
|
||||
|
||||
return <GatewayDeploymentMethodSelect onSelect={setSelectedMethod} />;
|
||||
};
|
||||
|
||||
export const GatewayDeployModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Deploy Gateway"
|
||||
subTitle="Select a deployment method to use for the gateway."
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<Content />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
GatewayDeploymentInfoMap,
|
||||
GatewayDeploymentMethod
|
||||
} from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal";
|
||||
|
||||
type Props = {
|
||||
onSelect: (method: GatewayDeploymentMethod) => void;
|
||||
};
|
||||
|
||||
export const GatewayDeploymentMethodSelect = ({ onSelect }: Props) => {
|
||||
const deploymentOptions = useMemo(
|
||||
() =>
|
||||
(Object.keys(GatewayDeploymentInfoMap) as GatewayDeploymentMethod[]).map((method) => ({
|
||||
method,
|
||||
name: GatewayDeploymentInfoMap[method].name,
|
||||
image: GatewayDeploymentInfoMap[method].image
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResourceSelect = (method: GatewayDeploymentMethod) => {
|
||||
onSelect(method);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-fit grid-cols-4 content-start gap-2">
|
||||
{deploymentOptions.map((option) => {
|
||||
const { image, name, method } = option;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => handleResourceSelect(method)}
|
||||
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`/images/integrations/${image}`}
|
||||
className="mt-auto w-12"
|
||||
alt={`${name} logo`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{name}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { components, OptionProps } from "react-select";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export const RelayOption = ({
|
||||
isSelected,
|
||||
children,
|
||||
...props
|
||||
}: OptionProps<{ id: string; name: string }>) => {
|
||||
const isCreateOption = props.data.id === "_create";
|
||||
|
||||
return (
|
||||
<components.Option isSelected={isSelected} {...props}>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
{isCreateOption ? (
|
||||
<div className="flex items-center gap-x-1 text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faPlus} size="sm" />
|
||||
<span className="mr-auto">Deploy New Relay</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate">{children}</p>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
+15
-6
@@ -1,15 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { GatewayTab } from "../GatewayTab/GatewayTab";
|
||||
import { RelayTab } from "../RelayTab/RelayTab";
|
||||
|
||||
export const NetworkingTabGroup = () => {
|
||||
const search = useSearch({
|
||||
from: "/_authenticate/_inject-org-details/_org-layout/organization/networking/"
|
||||
const navigate = useNavigate({
|
||||
from: ROUTE_PATHS.Organization.NetworkingPage.path
|
||||
});
|
||||
const selectedTab = useSearch({
|
||||
from: ROUTE_PATHS.Organization.NetworkingPage.id,
|
||||
select: (el) => el.selectedTab,
|
||||
structuralSharing: true
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
@@ -17,12 +22,16 @@ export const NetworkingTabGroup = () => {
|
||||
{ name: "Relays", key: "relays", component: RelayTab }
|
||||
];
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key);
|
||||
const handleTabChange = (tab: string) => {
|
||||
navigate({
|
||||
search: { selectedTab: tab }
|
||||
});
|
||||
};
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={handleTabChange}>
|
||||
<TabList>
|
||||
{tabs.map((tab) => (
|
||||
<Tab variant={isSubOrganization ? "namespace" : "org"} value={tab.key} key={tab.key}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
faEllipsisV,
|
||||
faInfoCircle,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { formatRelative } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
OrgPermissionSubjects,
|
||||
OrgRelayPermissionActions
|
||||
@@ -42,6 +46,8 @@ import { withPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays";
|
||||
|
||||
import { RelayDeployModal } from "./components/RelayDeployModal";
|
||||
|
||||
const RelayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => {
|
||||
const heartbeatDate = heartbeat ? new Date(heartbeat) : null;
|
||||
const now = new Date();
|
||||
@@ -66,7 +72,29 @@ export const RelayTab = withPermission(
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: relays, isPending: isRelaysLoading } = useGetRelays();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteRelay"] as const);
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"deleteRelay",
|
||||
"deployRelay"
|
||||
] as const);
|
||||
|
||||
const action = useSearch({
|
||||
from: ROUTE_PATHS.Organization.NetworkingPage.id,
|
||||
select: (s) => s.action
|
||||
});
|
||||
|
||||
const navigate = useNavigate({
|
||||
from: ROUTE_PATHS.Organization.NetworkingPage.path
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (action === "deploy-relay") {
|
||||
handlePopUpOpen("deployRelay");
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, action: undefined }),
|
||||
replace: true
|
||||
});
|
||||
}
|
||||
}, [action, handlePopUpOpen, navigate]);
|
||||
|
||||
const deleteRelayById = useDeleteRelayById();
|
||||
|
||||
@@ -87,8 +115,8 @@ export const RelayTab = withPermission(
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex grow items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Relays</h3>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/gateways/relay-deployment"
|
||||
@@ -104,6 +132,14 @@ export const RelayTab = withPermission(
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div className="flex grow" />
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("deployRelay")}
|
||||
>
|
||||
Deploy Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-mineshaft-400">
|
||||
@@ -222,6 +258,10 @@ export const RelayTab = withPermission(
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleDeleteRelay()}
|
||||
/>
|
||||
<RelayDeployModal
|
||||
isOpen={popUp.deployRelay.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deployRelay", isOpen)}
|
||||
/>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faCopy, faQuestionCircle, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FilterableSelect,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
ModalClose,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useOrgPermission
|
||||
} from "@app/context";
|
||||
import {
|
||||
useAddIdentityTokenAuth,
|
||||
useCreateTokenIdentityTokenAuth,
|
||||
useGetIdentityMembershipOrgs,
|
||||
useGetIdentityTokenAuth
|
||||
} from "@app/hooks/api";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const baseFormSchema = z.object({
|
||||
name: slugSchema({ field: "name" }),
|
||||
host: z.string().min(1, "Host is required"),
|
||||
instanceDomain: z.string().url("Must be a valid URL").or(z.literal(""))
|
||||
});
|
||||
|
||||
const formSchemaWithIdentity = baseFormSchema.extend({
|
||||
identity: z
|
||||
.object(
|
||||
{
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
},
|
||||
{ required_error: "Identity is required" }
|
||||
)
|
||||
.nullable()
|
||||
.refine((val) => val !== null, { message: "Identity is required" })
|
||||
});
|
||||
|
||||
const formSchemaWithToken = baseFormSchema.extend({
|
||||
identityToken: z.string().min(1, "Token is required")
|
||||
});
|
||||
|
||||
export const RelayCliDeploymentMethod = () => {
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port && port !== "80" ? `:${port}` : "";
|
||||
const siteURL = `${protocol}//${hostname}${portSuffix}`;
|
||||
|
||||
const [autogenerateToken, setAutogenerateToken] = useState(true);
|
||||
const [step, setStep] = useState<"form" | "command">("form");
|
||||
const [name, setName] = useState("");
|
||||
const [host, setHost] = useState("");
|
||||
|
||||
const [instanceDomain, setInstanceDomain] = useState(siteURL);
|
||||
const [identity, setIdentity] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
}>(null);
|
||||
const [identityToken, setIdentityToken] = useState("");
|
||||
const [formErrors, setFormErrors] = useState<z.ZodIssue[]>([]);
|
||||
|
||||
const errors = useMemo(() => {
|
||||
const errorMap: Record<string, string | undefined> = {};
|
||||
formErrors.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errorMap[String(issue.path[0])] = issue.message;
|
||||
}
|
||||
});
|
||||
return errorMap;
|
||||
}, [formErrors]);
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const organizationId = currentOrg?.id || "";
|
||||
|
||||
const { permission } = useOrgPermission();
|
||||
const canCreateToken = permission.can(
|
||||
OrgPermissionIdentityActions.CreateToken,
|
||||
OrgPermissionSubjects.Identity
|
||||
);
|
||||
|
||||
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
|
||||
useGetIdentityMembershipOrgs({
|
||||
organizationId,
|
||||
limit: 20000
|
||||
});
|
||||
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
|
||||
|
||||
const { mutateAsync: createToken, isPending: isCreatingToken } =
|
||||
useCreateTokenIdentityTokenAuth();
|
||||
const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } =
|
||||
useAddIdentityTokenAuth();
|
||||
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
|
||||
|
||||
const handleGenerateCommand = async () => {
|
||||
setFormErrors([]);
|
||||
|
||||
if (canCreateToken && autogenerateToken) {
|
||||
const validation = formSchemaWithIdentity.safeParse({ name, host, instanceDomain, identity });
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedIdentity = validation.data.identity;
|
||||
|
||||
try {
|
||||
const { data: identityTokenAuth } = await refetch();
|
||||
if (!identityTokenAuth) {
|
||||
await addIdentityTokenAuth({
|
||||
identityId: validatedIdentity.id,
|
||||
organizationId,
|
||||
accessTokenTTL: 2592000,
|
||||
accessTokenMaxTTL: 2592000,
|
||||
accessTokenNumUsesLimit: 0,
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
|
||||
});
|
||||
createNotification({
|
||||
text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.",
|
||||
type: "warning"
|
||||
});
|
||||
}
|
||||
|
||||
const token = await createToken({
|
||||
identityId: validatedIdentity.id,
|
||||
name: `relay token for ${name} (autogenerated)`
|
||||
});
|
||||
setIdentityToken(token.accessToken);
|
||||
createNotification({
|
||||
text: "Automatically generated a token for the selected identity.",
|
||||
type: "info"
|
||||
});
|
||||
setStep("command");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to generate token for the selected identity",
|
||||
type: "error"
|
||||
});
|
||||
setIdentityToken("");
|
||||
}
|
||||
} else {
|
||||
const validation = formSchemaWithToken.safeParse({
|
||||
name,
|
||||
host,
|
||||
instanceDomain,
|
||||
identityToken
|
||||
});
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
}
|
||||
setStep("command");
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdentityChange = (
|
||||
selectedIdentity: SingleValue<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>
|
||||
) => {
|
||||
setIdentity(selectedIdentity);
|
||||
};
|
||||
|
||||
const command = useMemo(() => {
|
||||
const domainFlag = instanceDomain ? ` --domain=${instanceDomain}` : "";
|
||||
return `infisical relay start --name=${name}${domainFlag} --host=${host} --token=${identityToken}`;
|
||||
}, [name, instanceDomain, host, identityToken]);
|
||||
|
||||
if (step === "command") {
|
||||
return (
|
||||
<>
|
||||
<FormLabel label="CLI Command" />
|
||||
<div className="flex gap-2">
|
||||
<Input value={command} isDisabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(command);
|
||||
createNotification({
|
||||
text: "Command copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<a
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>Install the Infisical CLI</span>
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
<div className="mt-6 flex items-center">
|
||||
<ModalClose asChild>
|
||||
<Button className="mr-4" size="sm" colorSchema="secondary">
|
||||
Done
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormLabel label="Name" tooltipText="The name for your relay." />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter relay name..."
|
||||
isError={Boolean(errors.name)}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red">{errors.name}</p>}
|
||||
|
||||
<FormLabel
|
||||
label="Host"
|
||||
tooltipText="The public IP address of the system you're deploying the relay to."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="0.0.0.0"
|
||||
isError={Boolean(errors.host)}
|
||||
/>
|
||||
{errors.host && <p className="mt-1 text-sm text-red">{errors.host}</p>}
|
||||
|
||||
<FormLabel
|
||||
label="Infisical Instance Host Address"
|
||||
tooltipText="The host address of the infisical instance that's accessible by the relay."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={instanceDomain}
|
||||
onChange={(e) => setInstanceDomain(e.target.value)}
|
||||
placeholder="https://app.infisical.com"
|
||||
isError={Boolean(errors.instanceDomain)}
|
||||
/>
|
||||
{errors.instanceDomain && <p className="mt-1 text-sm text-red">{errors.instanceDomain}</p>}
|
||||
|
||||
{canCreateToken && autogenerateToken ? (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Identity"
|
||||
tooltipText="The identity that your relay will use for authentication."
|
||||
className="mt-4"
|
||||
/>
|
||||
<FilterableSelect
|
||||
value={identity}
|
||||
onChange={(e) =>
|
||||
handleIdentityChange(
|
||||
e as SingleValue<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>
|
||||
)
|
||||
}
|
||||
isLoading={isIdentitiesLoading}
|
||||
placeholder="Select identity..."
|
||||
options={identityMembershipOrgs.map((membership) => membership.identity)}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
{errors.identity && <p className="mt-1 text-sm text-red">{errors.identity}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Identity Token"
|
||||
tooltipText="The identity token that your relay will use for authentication."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={identityToken}
|
||||
onChange={(e) => setIdentityToken(e.target.value)}
|
||||
placeholder="Enter identity token..."
|
||||
isError={Boolean(errors.identityToken)}
|
||||
/>
|
||||
{errors.identityToken && <p className="mt-1 text-sm text-red">{errors.identityToken}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCreateToken && (
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
isChecked={autogenerateToken}
|
||||
onCheckedChange={(e) => {
|
||||
setAutogenerateToken(Boolean(e));
|
||||
}}
|
||||
id="autogenerate-token"
|
||||
className="mr-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>Automatically enable token auth and generate a token for identity</span>
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<>
|
||||
Token authentication will be automatically enabled for the selected identity if
|
||||
it isn't already configured. By default, it will be configured to allow all
|
||||
IP addresses with a token TTL of 30 days. You can manage these settings in
|
||||
Access Control.
|
||||
<br />
|
||||
<br />A token will automatically be generated to be used with the CLI command.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="mt-0.5 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
onClick={handleGenerateCommand}
|
||||
isLoading={isCreatingToken || isAddingTokenAuth}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect";
|
||||
|
||||
import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const RelayDeploymentInfoMap = {
|
||||
cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod }
|
||||
} as const;
|
||||
|
||||
export type RelayDeploymentMethod = keyof typeof RelayDeploymentInfoMap;
|
||||
|
||||
const Content = () => {
|
||||
const [selectedMethod, setSelectedMethod] = useState<null | RelayDeploymentMethod>(null);
|
||||
|
||||
if (selectedMethod) {
|
||||
const ComponentToRender = RelayDeploymentInfoMap[selectedMethod]?.component;
|
||||
if (ComponentToRender) {
|
||||
return <ComponentToRender />;
|
||||
}
|
||||
}
|
||||
|
||||
return <RelayDeploymentMethodSelect onSelect={setSelectedMethod} />;
|
||||
};
|
||||
|
||||
export const RelayDeployModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Deploy Relay"
|
||||
subTitle="Select a deployment method to use for the relay."
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<Content />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
RelayDeploymentInfoMap,
|
||||
RelayDeploymentMethod
|
||||
} from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal";
|
||||
|
||||
type Props = {
|
||||
onSelect: (method: RelayDeploymentMethod) => void;
|
||||
};
|
||||
|
||||
export const RelayDeploymentMethodSelect = ({ onSelect }: Props) => {
|
||||
const deploymentOptions = useMemo(
|
||||
() =>
|
||||
(Object.keys(RelayDeploymentInfoMap) as RelayDeploymentMethod[]).map((method) => ({
|
||||
method,
|
||||
name: RelayDeploymentInfoMap[method].name,
|
||||
image: RelayDeploymentInfoMap[method].image
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResourceSelect = (method: RelayDeploymentMethod) => {
|
||||
onSelect(method);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-fit grid-cols-4 content-start gap-2">
|
||||
{deploymentOptions.map((option) => {
|
||||
const { image, name, method } = option;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => handleResourceSelect(method)}
|
||||
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`/images/integrations/${image}`}
|
||||
className="mt-auto w-12"
|
||||
alt={`${name} logo`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{name}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,16 +5,17 @@ import { z } from "zod";
|
||||
import { NetworkingPage } from "./NetworkingPage";
|
||||
|
||||
const NetworkingPageQueryParams = z.object({
|
||||
selectedTab: z.string().catch("")
|
||||
selectedTab: z.string().catch("gateways"),
|
||||
action: z.string().optional()
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking/"
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking"
|
||||
)({
|
||||
component: NetworkingPage,
|
||||
validateSearch: zodValidator(NetworkingPageQueryParams),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ selectedTab: "" })]
|
||||
middlewares: [stripSearchParams({ selectedTab: "gateways" })]
|
||||
},
|
||||
context: () => ({
|
||||
breadcrumbs: [
|
||||
|
||||
+9
-6
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -29,6 +30,8 @@ const formSchema = z.object({
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const isUpdate = Boolean(auditLogStream);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
@@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Bearer <token>"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={(e) => {
|
||||
placeholder="Bearer <token>"
|
||||
onFocus={() => {
|
||||
if (
|
||||
auditLogStream &&
|
||||
auditLogStream.credentials.headers[i] &&
|
||||
@@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
|
||||
) {
|
||||
field.onChange("");
|
||||
}
|
||||
e.target.type = "text";
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur={() => {
|
||||
if (
|
||||
auditLogStream &&
|
||||
auditLogStream.credentials.headers[i] &&
|
||||
@@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
|
||||
) {
|
||||
field.onChange("******");
|
||||
}
|
||||
e.target.type = "password";
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -19,12 +19,53 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
|
||||
|
||||
const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]);
|
||||
|
||||
const cliDuration = useMemo(() => {
|
||||
if (!duration) return duration;
|
||||
|
||||
const unit = duration.replace(/[\d\s.-]/g, "");
|
||||
|
||||
const dayOrLargerUnits = [
|
||||
"d",
|
||||
"day",
|
||||
"days",
|
||||
"w",
|
||||
"week",
|
||||
"weeks",
|
||||
"y",
|
||||
"yr",
|
||||
"yrs",
|
||||
"year",
|
||||
"years"
|
||||
];
|
||||
|
||||
// ms library does not handle months (M) so we do it separately
|
||||
if (unit === "M") {
|
||||
const value = parseInt(duration, 10);
|
||||
if (!Number.isNaN(value) && value > 0) {
|
||||
const hours = value * 30 * 24;
|
||||
return `${hours}h`;
|
||||
}
|
||||
} else if (dayOrLargerUnits.includes(unit.toLowerCase())) {
|
||||
const valueInMs = ms(duration);
|
||||
const oneHourInMs = 1000 * 60 * 60;
|
||||
|
||||
if (typeof valueInMs === "number" && valueInMs > 0) {
|
||||
const hours = Math.floor(valueInMs / oneHourInMs);
|
||||
return `${hours}h`;
|
||||
}
|
||||
}
|
||||
|
||||
return duration;
|
||||
}, [duration]);
|
||||
|
||||
const command = useMemo(
|
||||
() =>
|
||||
account && account.resource.resourceType === PamResourceType.Postgres
|
||||
? `infisical pam db access-account ${account.id} --duration ${duration}`
|
||||
account &&
|
||||
(account.resource.resourceType === PamResourceType.Postgres ||
|
||||
account.resource.resourceType === PamResourceType.MySQL)
|
||||
? `infisical pam db access-account ${account.id} --duration ${cliDuration}`
|
||||
: "",
|
||||
[account, duration]
|
||||
[account, cliDuration]
|
||||
);
|
||||
|
||||
if (!account) return null;
|
||||
@@ -48,7 +89,7 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
|
||||
/>
|
||||
<FormLabel label="CLI Command" className="mt-4" />
|
||||
<div className="flex gap-2">
|
||||
<Input value={command} isDisabled className="opacity-50" />
|
||||
<Input value={command} isDisabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { PamResourceType, TMySQLAccount } from "@app/hooks/api/pam";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TMySQLAccount;
|
||||
resourceId?: string;
|
||||
resourceType?: PamResourceType;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: BaseSqlAccountSchema,
|
||||
// We don't support rotation for now, just feed a false value to
|
||||
// make the schema happy
|
||||
rotationEnabled: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const MySQLAccountForm = ({ account, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: account
|
||||
? {
|
||||
...account,
|
||||
credentials: {
|
||||
...account.credentials,
|
||||
password: UNCHANGED_PASSWORD_SENTINEL
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Account" : "Create Account"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+14
-3
@@ -8,6 +8,7 @@ import {
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { PamAccountHeader } from "../PamAccountHeader";
|
||||
import { MySQLAccountForm } from "./MySQLAccountForm";
|
||||
import { PostgresAccountForm } from "./PostgresAccountForm";
|
||||
|
||||
type FormProps = {
|
||||
@@ -34,8 +35,6 @@ const CreateForm = ({
|
||||
}: CreateFormProps) => {
|
||||
const createPamAccount = useCreatePamAccount();
|
||||
|
||||
console.log({ folderId });
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
) => {
|
||||
@@ -64,7 +63,17 @@ const CreateForm = ({
|
||||
|
||||
switch (resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresAccountForm onSubmit={onSubmit} />;
|
||||
return (
|
||||
<PostgresAccountForm
|
||||
onSubmit={onSubmit}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
);
|
||||
case PamResourceType.MySQL:
|
||||
return (
|
||||
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -100,6 +109,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
switch (account.resource.resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
|
||||
case PamResourceType.MySQL:
|
||||
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
||||
}
|
||||
|
||||
+33
-6
@@ -1,26 +1,36 @@
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { TPostgresAccount } from "@app/hooks/api/pam";
|
||||
import {
|
||||
PamResourceType,
|
||||
TPostgresAccount,
|
||||
TPostgresResource,
|
||||
useGetPamResourceById
|
||||
} from "@app/hooks/api/pam";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TPostgresAccount;
|
||||
resourceId?: string;
|
||||
resourceType?: PamResourceType;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
|
||||
credentials: BaseSqlAccountSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
@@ -30,7 +40,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
...account,
|
||||
credentials: {
|
||||
...account.credentials,
|
||||
password: "******"
|
||||
password: UNCHANGED_PASSWORD_SENTINEL
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
@@ -41,6 +51,22 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false);
|
||||
|
||||
const { data: resource } = useGetPamResourceById(resourceType, resourceId, {
|
||||
enabled: !account && !!resourceId && !!resourceType
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured);
|
||||
} else {
|
||||
setRotationCredentialsConfigured(
|
||||
!!(resource as TPostgresResource)?.rotationAccountCredentials
|
||||
);
|
||||
}
|
||||
}, [account, resource]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
@@ -50,6 +76,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/components/v2";
|
||||
|
||||
export const rotateAccountFieldsSchema = z.object({
|
||||
rotationEnabled: z.boolean(),
|
||||
rotationIntervalSeconds: z.number().nullable().optional()
|
||||
});
|
||||
|
||||
export const RotateAccountFields = ({
|
||||
rotationCredentialsConfigured
|
||||
}: {
|
||||
rotationCredentialsConfigured: boolean;
|
||||
}) => {
|
||||
const { control, watch } = useFormContext<{
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
}>();
|
||||
|
||||
const rotationEnabled = watch("rotationEnabled");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
isDisabled={rotationCredentialsConfigured}
|
||||
content="The resource which owns this account does not have rotation credentials configured."
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-9 w-fit items-center gap-3",
|
||||
!rotationCredentialsConfigured && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="rotationEnabled"
|
||||
defaultValue={false}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-0">
|
||||
<Switch
|
||||
className="ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="rotation-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
onCheckedChange={onChange}
|
||||
isChecked={value}
|
||||
isDisabled={!rotationCredentialsConfigured}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">Rotate Credentials Every</span>
|
||||
|
||||
<Controller
|
||||
name="rotationIntervalSeconds"
|
||||
control={control}
|
||||
defaultValue={2592000}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
value={(value || 2592000).toString()}
|
||||
onValueChange={(val) => onChange(parseInt(val, 10))}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
placeholder="Select an interval..."
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isDisabled={!rotationEnabled || !rotationCredentialsConfigured}
|
||||
dropdownContainerStyle={{
|
||||
width: "130px"
|
||||
}}
|
||||
>
|
||||
<SelectItem value="2592000">30 Days</SelectItem>
|
||||
<SelectItem value="604800">7 Days</SelectItem>
|
||||
<SelectItem value="259200">3 Days</SelectItem>
|
||||
<SelectItem value="86400">1 Day</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+20
-9
@@ -1,9 +1,19 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { FormControl, Input } from "@app/components/v2";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const { control } = useFormContext();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const password = useWatch({ control, name: "credentials.password" });
|
||||
|
||||
useEffect(() => {
|
||||
if (password === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
@@ -17,7 +27,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
>
|
||||
<Input {...field} />
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -33,18 +43,19 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
onFocus={(e) => {
|
||||
if (isUpdate && field.value === "******") {
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={() => {
|
||||
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
field.onChange("");
|
||||
}
|
||||
e.target.type = "text";
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur={() => {
|
||||
if (isUpdate && field.value === "") {
|
||||
field.onChange("******");
|
||||
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||
}
|
||||
e.target.type = "password";
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
+6
-2
@@ -5,6 +5,10 @@ export const BaseSqlAccountSchema = z.object({
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username required")
|
||||
.max(255, "Username must be 255 characters or less"),
|
||||
password: z.string().trim().min(1, "Password required")
|
||||
.max(63, "Username must be 63 characters or less"),
|
||||
password: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Password required")
|
||||
.max(256, "Password must be 256 characters or less")
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
faEllipsisV,
|
||||
faFolder,
|
||||
faRightToBracket,
|
||||
faRotate,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -100,6 +102,12 @@ export const PamAccountRow = ({
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{account.lastRotatedAt && (
|
||||
<Badge className="flex h-5 w-min items-center gap-1.5 bg-orange/20 whitespace-nowrap text-orange">
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
<span>Rotated {formatDistance(new Date(), account.lastRotatedAt)} ago</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { PamResourceType, TMySQLResource } from "@app/hooks/api/pam";
|
||||
|
||||
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
|
||||
import { SqlResourceFields } from "./shared/SqlResourceFields";
|
||||
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
|
||||
|
||||
type Props = {
|
||||
resource?: TMySQLResource;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericResourceFieldsSchema.extend({
|
||||
resourceType: z.literal(PamResourceType.MySQL),
|
||||
connectionDetails: BaseSqlResourceSchema.extend({
|
||||
database: z.string().trim().optional().default("")
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const MySQLResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(resource);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.MySQL,
|
||||
connectionDetails: {
|
||||
host: "",
|
||||
port: 3306,
|
||||
database: "",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
setSelectedTabIndex(0);
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<GenericResourceFields />
|
||||
<SqlResourceFields
|
||||
selectedTabIndex={selectedTabIndex}
|
||||
setSelectedTabIndex={setSelectedTabIndex}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Details" : "Create Resource"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+6
-1
@@ -9,6 +9,7 @@ import {
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { PamResourceHeader } from "../PamResourceHeader";
|
||||
import { MySQLResourceForm } from "./MySQLResourceForm";
|
||||
import { PostgresResourceForm } from "./PostgresResourceForm";
|
||||
|
||||
type FormProps = {
|
||||
@@ -57,6 +58,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
|
||||
switch (resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.MySQL:
|
||||
return <MySQLResourceForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -92,8 +95,10 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
|
||||
switch (resource.resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.MySQL:
|
||||
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resource.resourceType}`);
|
||||
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+27
-12
@@ -5,9 +5,12 @@ import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
import { BaseSqlAccountSchema } from "@app/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas";
|
||||
|
||||
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
|
||||
import { SqlResourceFields } from "./shared/SqlResourceFields";
|
||||
import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields";
|
||||
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
|
||||
|
||||
type Props = {
|
||||
@@ -17,7 +20,8 @@ type Props = {
|
||||
|
||||
const formSchema = genericResourceFieldsSchema.extend({
|
||||
resourceType: z.literal(PamResourceType.Postgres),
|
||||
connectionDetails: BaseSqlResourceSchema
|
||||
connectionDetails: BaseSqlResourceSchema,
|
||||
rotationAccountCredentials: BaseSqlAccountSchema.nullable().optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -28,17 +32,27 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.Postgres,
|
||||
connectionDetails: {
|
||||
host: "",
|
||||
port: 5432,
|
||||
database: "default",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
defaultValues: resource
|
||||
? {
|
||||
...resource,
|
||||
rotationAccountCredentials: resource.rotationAccountCredentials
|
||||
? {
|
||||
...resource.rotationAccountCredentials,
|
||||
password: UNCHANGED_PASSWORD_SENTINEL
|
||||
}
|
||||
: resource.rotationAccountCredentials
|
||||
}
|
||||
: {
|
||||
resourceType: PamResourceType.Postgres,
|
||||
connectionDetails: {
|
||||
host: "",
|
||||
port: 5432,
|
||||
database: "default",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -59,6 +73,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
selectedTabIndex={selectedTabIndex}
|
||||
setSelectedTabIndex={setSelectedTabIndex}
|
||||
/>
|
||||
<SqlRotateAccountFields isUpdate={isUpdate} />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ export const SqlResourceFields = ({ setSelectedTabIndex, selectedTabIndex }: Pro
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
label="SSL Certificate"
|
||||
label="Trusted CA SSL Certificate"
|
||||
isOptional
|
||||
>
|
||||
<TextArea className="h-14 resize-none!" {...field} isDisabled={!sslEnabled} />
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { FormControl, Input, Switch } from "@app/components/v2";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const { control, setValue, getValues } = useFormContext();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const password = useWatch({ control, name: "credentials.password" });
|
||||
|
||||
const rotationUsername = useWatch({ control, name: "rotationAccountCredentials.username" });
|
||||
const rotationPassword = useWatch({ control, name: "rotationAccountCredentials.password" });
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [wasRotationPasswordSentinelInitially, setWasRotationPasswordSentinelInitially] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initialRotationPass = getValues("rotationAccountCredentials.password");
|
||||
if (initialRotationPass === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
setWasRotationPasswordSentinelInitially(true);
|
||||
}
|
||||
}, [getValues]);
|
||||
|
||||
useEffect(() => {
|
||||
if (password === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
useEffect(() => {
|
||||
const isUsernamePopulated = rotationUsername && rotationUsername !== "";
|
||||
const isPasswordPopulated =
|
||||
rotationPassword &&
|
||||
rotationPassword !== "" &&
|
||||
rotationPassword !== UNCHANGED_PASSWORD_SENTINEL;
|
||||
|
||||
if (isUsernamePopulated || isPasswordPopulated) {
|
||||
setEnabled(true);
|
||||
}
|
||||
}, [rotationUsername, rotationPassword]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Switch
|
||||
id="account-rotation"
|
||||
onCheckedChange={(value) => {
|
||||
setEnabled(value);
|
||||
if (value) {
|
||||
setValue("rotationAccountCredentials.username", "", {
|
||||
shouldDirty: true
|
||||
});
|
||||
setValue("rotationAccountCredentials.password", "", {
|
||||
shouldDirty: true
|
||||
});
|
||||
} else {
|
||||
setValue("rotationAccountCredentials", null, {
|
||||
shouldDirty: true
|
||||
});
|
||||
}
|
||||
}}
|
||||
isChecked={enabled}
|
||||
containerClassName="flex-row-reverse w-fit"
|
||||
className="ml-0"
|
||||
>
|
||||
<p className="ml-2">Credential Rotation</p>
|
||||
</Switch>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
name="rotationAccountCredentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0 flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
>
|
||||
<div className="relative">
|
||||
<Input {...field} autoComplete="off" />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="rotationAccountCredentials.password"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0 flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={() => {
|
||||
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
field.onChange("");
|
||||
}
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (
|
||||
isUpdate &&
|
||||
field.value === "" &&
|
||||
wasRotationPasswordSentinelInitially
|
||||
) {
|
||||
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||
}
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-xs text-mineshaft-400">
|
||||
Credentials of the privileged account which will be used for rotating other accounts
|
||||
under this resource
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1172,7 +1172,9 @@ export const OverviewPage = () => {
|
||||
handlePopUpClose("misc");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
}}
|
||||
isDisabled={userAvailableDynamicSecretEnvs.length === 0}
|
||||
variant="outline_bg"
|
||||
@@ -1679,10 +1681,11 @@ export const OverviewPage = () => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
text={
|
||||
subscription.slug === null
|
||||
? "You can perform this action under an Enterprise license"
|
||||
: "You can perform this action if you switch to Infisical's Team plan"
|
||||
: "You can perform this action if you switch to Infisical's Enterprise plan"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1109,7 +1109,6 @@ const Page = () => {
|
||||
secretPath={secretPath}
|
||||
isSmaller={isNotEmpty}
|
||||
environments={currentProject?.environments}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
<PitDrawer
|
||||
secretSnaphots={snapshotList}
|
||||
|
||||
+7
-2
@@ -1019,7 +1019,9 @@ export const ActionBar = ({
|
||||
handlePopUpClose("misc");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
@@ -1274,10 +1276,13 @@ export const ActionBar = ({
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
text={
|
||||
subscription.slug === null
|
||||
? "You can perform this action under an Enterprise license"
|
||||
: "You can perform this action if you switch to Infisical's Team plan"
|
||||
: `You can perform this action if you switch to Infisical's ${
|
||||
popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"
|
||||
} plan`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -226,7 +226,7 @@ export const EnvironmentTabs = ({ secretPath }: Props) => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add custom environments if you switch to Infisical's Team plan."
|
||||
text="You can add custom environments if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
<AddEnvironmentModal
|
||||
isOpen={popUp.createEnvironment.isOpen}
|
||||
|
||||
+117
-154
@@ -10,7 +10,6 @@ import {
|
||||
faUpload
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -34,21 +33,27 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
||||
import {
|
||||
dashboardKeys,
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { fetchProjectSecrets, mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
|
||||
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
|
||||
import {
|
||||
BatchContext,
|
||||
PendingSecretCreate,
|
||||
PendingSecretUpdate,
|
||||
PopUpNames,
|
||||
useBatchModeActions,
|
||||
usePopUpAction
|
||||
} from "../../SecretMainPage.store";
|
||||
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
|
||||
import { PasteSecretEnvModal } from "./PasteSecretEnvModal";
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[] }>;
|
||||
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
|
||||
type TSecOverwriteOpt = {
|
||||
update: TParsedEnv;
|
||||
create: TParsedEnv;
|
||||
existingSecrets: SecretV3RawSanitized[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
@@ -56,7 +61,6 @@ type Props = {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
isProtectedBranch?: boolean;
|
||||
};
|
||||
|
||||
type SecretMatrixMap = {
|
||||
@@ -65,7 +69,7 @@ type SecretMatrixMap = {
|
||||
comment: number | null;
|
||||
};
|
||||
|
||||
const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const;
|
||||
const popupKeys = ["importSecEnv", "pasteSecEnv", "importMatrixMap"] as const;
|
||||
|
||||
const MatrixImportModalTableRow = ({
|
||||
importSecretMatrixMap,
|
||||
@@ -142,8 +146,7 @@ export const SecretDropzone = ({
|
||||
environments = [],
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
isProtectedBranch = false
|
||||
secretPath
|
||||
}: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
@@ -157,18 +160,11 @@ export const SecretDropzone = ({
|
||||
});
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
|
||||
const queryClient = useQueryClient();
|
||||
const { openPopUp } = usePopUpAction();
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
// hide copy secrets from board due to import folders feature
|
||||
const shouldRenderCopySecrets = false;
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -180,6 +176,81 @@ export const SecretDropzone = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSecrets = async (data: TSecOverwriteOpt) => {
|
||||
const { update, create, existingSecrets } = data;
|
||||
|
||||
try {
|
||||
const context: BatchContext = {
|
||||
projectId,
|
||||
environment,
|
||||
secretPath
|
||||
};
|
||||
|
||||
const existingSecretsMap = existingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: curr }),
|
||||
{}
|
||||
);
|
||||
|
||||
const totalCount = Object.keys(create || {}).length + Object.keys(update || {}).length;
|
||||
|
||||
if (Object.keys(create || {}).length) {
|
||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
||||
const createChange: PendingSecretCreate = {
|
||||
id: secretKey,
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret",
|
||||
type: PendingAction.Create,
|
||||
secretKey,
|
||||
secretValue: secData.value,
|
||||
secretComment: secData.comments.join("\n") || undefined,
|
||||
tags: [],
|
||||
secretMetadata: []
|
||||
};
|
||||
addPendingChange(createChange, context);
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(update || {}).length) {
|
||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
||||
const existingSecret = existingSecretsMap[secretKey];
|
||||
|
||||
if (!existingSecret) {
|
||||
console.warn(`Existing secret not found for key: ${secretKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateChange: PendingSecretUpdate = {
|
||||
id: existingSecret.id,
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret",
|
||||
type: PendingAction.Update,
|
||||
secretKey,
|
||||
secretValue: secData.value,
|
||||
secretComment: secData.comments.join("\n") || undefined,
|
||||
existingSecret,
|
||||
originalValue: existingSecret.value || "",
|
||||
originalComment: existingSecret.comment || "",
|
||||
originalSkipMultilineEncoding: existingSecret.skipMultilineEncoding || false,
|
||||
originalTags: existingSecret.tags || [],
|
||||
originalSecretMetadata: existingSecret.secretMetadata || []
|
||||
};
|
||||
addPendingChange(updateChange, context);
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully imported ${totalCount} secret${totalCount > 1 ? "s" : ""}.`
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to import secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleParsedEnv = async (env: TParsedEnv) => {
|
||||
const envSecretKeys = Object.keys(env);
|
||||
|
||||
@@ -193,29 +264,38 @@ export const SecretDropzone = ({
|
||||
|
||||
try {
|
||||
setIsLoading.on();
|
||||
const { secrets: existingSecrets } = await fetchDashboardProjectSecretsByKeys({
|
||||
secretPath,
|
||||
environment,
|
||||
const { secrets: rawExistingSecrets } = await fetchProjectSecrets({
|
||||
projectId,
|
||||
keys: envSecretKeys
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue: true
|
||||
});
|
||||
|
||||
const secretsGroupedByKey = existingSecrets.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.secretKey]: true }),
|
||||
const allExistingSecrets = mergePersonalSecrets(rawExistingSecrets);
|
||||
|
||||
const existingSecretsMap = allExistingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: curr }),
|
||||
{}
|
||||
);
|
||||
|
||||
const updateSecrets = Object.keys(env)
|
||||
.filter((secKey) => secretsGroupedByKey[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
const updateSecrets: TParsedEnv = {};
|
||||
const createSecrets: TParsedEnv = {};
|
||||
const relevantExistingSecrets: SecretV3RawSanitized[] = [];
|
||||
|
||||
const createSecrets = Object.keys(env)
|
||||
.filter((secKey) => !secretsGroupedByKey[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
Object.entries(env).forEach(([secretKey, secretData]) => {
|
||||
const existingSecret = existingSecretsMap[secretKey];
|
||||
if (existingSecret) {
|
||||
updateSecrets[secretKey] = secretData;
|
||||
relevantExistingSecrets.push(existingSecret);
|
||||
} else {
|
||||
createSecrets[secretKey] = secretData;
|
||||
}
|
||||
});
|
||||
|
||||
handlePopUpOpen("confirmUpload", {
|
||||
await handleSaveSecrets({
|
||||
update: updateSecrets,
|
||||
create: createSecrets
|
||||
create: createSecrets,
|
||||
existingSecrets: relevantExistingSecrets
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -223,7 +303,6 @@ export const SecretDropzone = ({
|
||||
text: "Failed to check for secret conflicts",
|
||||
type: "error"
|
||||
});
|
||||
handlePopUpClose("confirmUpload");
|
||||
} finally {
|
||||
setIsLoading.off();
|
||||
}
|
||||
@@ -328,70 +407,6 @@ export const SecretDropzone = ({
|
||||
parseFile(e.target?.files?.[0]);
|
||||
};
|
||||
|
||||
const handleSaveSecrets = async () => {
|
||||
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
|
||||
try {
|
||||
if (Object.keys(create || {}).length) {
|
||||
await createSecretBatch({
|
||||
secretPath,
|
||||
projectId,
|
||||
environment,
|
||||
secrets: Object.entries(create).map(([secretKey, secData]) => ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (Object.keys(update || {}).length) {
|
||||
await updateSecretBatch({
|
||||
secretPath,
|
||||
projectId,
|
||||
environment,
|
||||
secrets: Object.entries(update).map(([secretKey, secData]) => ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
}))
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({ projectId, environment, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretApprovalRequestKeys.count({ projectId })
|
||||
});
|
||||
handlePopUpClose("confirmUpload");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: isProtectedBranch
|
||||
? "Uploaded changes have been sent for review"
|
||||
: "Successfully uploaded secrets"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to upload secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
|
||||
).length;
|
||||
|
||||
const updateSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
|
||||
).length;
|
||||
|
||||
const isNonConflictingUpload = !updateSecretCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -495,58 +510,6 @@ export const SecretDropzone = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={popUp?.confirmUpload?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Confirm Secret Upload"
|
||||
footerContent={[
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
|
||||
key="overwrite-btn"
|
||||
onClick={handleSaveSecrets}
|
||||
>
|
||||
{isNonConflictingUpload ? "Upload" : "Overwrite"}
|
||||
</Button>,
|
||||
<Button
|
||||
key="keep-old-btn"
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpClose("confirmUpload")}
|
||||
variant="outline_bg"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{isNonConflictingUpload ? (
|
||||
<div>
|
||||
Are you sure you want to import {createSecretCount} secret
|
||||
{createSecretCount > 1 ? "s" : ""} to this environment?
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col text-gray-300">
|
||||
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
Are you sure you want to overwrite these secrets
|
||||
{createSecretCount > 0
|
||||
? ` and import ${createSecretCount} new
|
||||
one${createSecretCount > 1 ? "s" : ""}`
|
||||
: ""}
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Matrix Import Modal */}
|
||||
<Modal
|
||||
|
||||
+2
-1
@@ -130,7 +130,8 @@ export const SecretImportListView = ({
|
||||
const [items, setItems] = useState(secretImports ?? []);
|
||||
|
||||
const getImportReplicatedFolder = (importPath: string) => {
|
||||
const cleanImportPath = importPath.replace("/__reserve_replication_", "");
|
||||
if (!importPath.includes("/__reserve_replication_")) return undefined;
|
||||
const cleanImportPath = importPath.split("/__reserve_replication_")[1];
|
||||
const replicatedFolder = items?.find(({ id }) => id === cleanImportPath);
|
||||
return replicatedFolder;
|
||||
};
|
||||
|
||||
@@ -397,7 +397,7 @@ const Page = () => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add secret rotation if you switch to Infisical's Team plan."
|
||||
text="You can add secret rotation if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.secretRotationV2.isOpen}
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ export const EnvironmentSection = () => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add custom environments if you switch to Infisical's Team plan."
|
||||
text="You can add custom environments if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Route as adminCachingPageRouteImport } from './pages/admin/CachingPage/
|
||||
import { Route as adminAuthenticationPageRouteImport } from './pages/admin/AuthenticationPage/route'
|
||||
import { Route as adminAccessManagementPageRouteImport } from './pages/admin/AccessManagementPage/route'
|
||||
import { Route as organizationProjectsPageRouteImport } from './pages/organization/ProjectsPage/route'
|
||||
import { Route as organizationNetworkingPageRouteImport } from './pages/organization/NetworkingPage/route'
|
||||
import { Route as organizationBillingPageRouteImport } from './pages/organization/BillingPage/route'
|
||||
import { Route as organizationAuditLogsPageRouteImport } from './pages/organization/AuditLogsPage/route'
|
||||
import { Route as organizationAccessManagementPageRouteImport } from './pages/organization/AccessManagementPage/route'
|
||||
@@ -62,7 +63,6 @@ import { Route as organizationIdentityDetailsByIDPageRouteImport } from './pages
|
||||
import { Route as organizationGroupDetailsByIDPageRouteImport } from './pages/organization/GroupDetailsByIDPage/route'
|
||||
import { Route as organizationSettingsPageRouteImport } from './pages/organization/SettingsPage/route'
|
||||
import { Route as organizationSecretSharingPageRouteImport } from './pages/organization/SecretSharingPage/route'
|
||||
import { Route as organizationNetworkingPageRouteImport } from './pages/organization/NetworkingPage/route'
|
||||
import { Route as organizationAppConnectionsAppConnectionsPageRouteImport } from './pages/organization/AppConnections/AppConnectionsPage/route'
|
||||
import { Route as sshLayoutImport } from './pages/ssh/layout'
|
||||
import { Route as secretScanningLayoutImport } from './pages/secret-scanning/layout'
|
||||
@@ -270,10 +270,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingImport =
|
||||
createFileRoute(
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing',
|
||||
)()
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingImport =
|
||||
createFileRoute(
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking',
|
||||
)()
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsImport =
|
||||
createFileRoute(
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/app-connections',
|
||||
@@ -594,14 +590,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRoute =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingImport.update({
|
||||
id: '/networking',
|
||||
path: '/networking',
|
||||
getParentRoute: () =>
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRoute =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsImport.update({
|
||||
id: '/app-connections',
|
||||
@@ -658,6 +646,14 @@ const organizationProjectsPageRouteRoute =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationRoute,
|
||||
} as any)
|
||||
|
||||
const organizationNetworkingPageRouteRoute =
|
||||
organizationNetworkingPageRouteImport.update({
|
||||
id: '/networking',
|
||||
path: '/networking',
|
||||
getParentRoute: () =>
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationRoute,
|
||||
} as any)
|
||||
|
||||
const organizationBillingPageRouteRoute =
|
||||
organizationBillingPageRouteImport.update({
|
||||
id: '/billing',
|
||||
@@ -799,14 +795,6 @@ const organizationSecretSharingPageRouteRoute =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute,
|
||||
} as any)
|
||||
|
||||
const organizationNetworkingPageRouteRoute =
|
||||
organizationNetworkingPageRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRoute,
|
||||
} as any)
|
||||
|
||||
const organizationAppConnectionsAppConnectionsPageRouteRoute =
|
||||
organizationAppConnectionsAppConnectionsPageRouteImport.update({
|
||||
id: '/',
|
||||
@@ -2476,6 +2464,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof organizationBillingPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/networking'
|
||||
path: '/networking'
|
||||
fullPath: '/organization/networking'
|
||||
preLoaderRoute: typeof organizationNetworkingPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/projects': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/projects'
|
||||
path: '/projects'
|
||||
@@ -2532,13 +2527,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/networking'
|
||||
path: '/networking'
|
||||
fullPath: '/organization/networking'
|
||||
preLoaderRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing'
|
||||
path: '/secret-sharing'
|
||||
@@ -2567,13 +2555,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof organizationAppConnectionsAppConnectionsPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking/': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/networking/'
|
||||
path: '/'
|
||||
fullPath: '/organization/networking/'
|
||||
preLoaderRoute: typeof organizationNetworkingPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/'
|
||||
path: '/'
|
||||
@@ -4001,20 +3982,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithCh
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteChildren {
|
||||
organizationNetworkingPageRouteRoute: typeof organizationNetworkingPageRouteRoute
|
||||
}
|
||||
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteChildren: AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteChildren =
|
||||
{
|
||||
organizationNetworkingPageRouteRoute: organizationNetworkingPageRouteRoute,
|
||||
}
|
||||
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteWithChildren =
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRoute._addFileChildren(
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteChildren {
|
||||
organizationSecretSharingPageRouteRoute: typeof organizationSecretSharingPageRouteRoute
|
||||
}
|
||||
@@ -4051,9 +4018,9 @@ interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren {
|
||||
organizationAccessManagementPageRouteRoute: typeof organizationAccessManagementPageRouteRoute
|
||||
organizationAuditLogsPageRouteRoute: typeof organizationAuditLogsPageRouteRoute
|
||||
organizationBillingPageRouteRoute: typeof organizationBillingPageRouteRoute
|
||||
organizationNetworkingPageRouteRoute: typeof organizationNetworkingPageRouteRoute
|
||||
organizationProjectsPageRouteRoute: typeof organizationProjectsPageRouteRoute
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteWithChildren
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRouteWithChildren
|
||||
organizationGroupDetailsByIDPageRouteRoute: typeof organizationGroupDetailsByIDPageRouteRoute
|
||||
@@ -4068,11 +4035,10 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren: Authentica
|
||||
organizationAccessManagementPageRouteRoute,
|
||||
organizationAuditLogsPageRouteRoute: organizationAuditLogsPageRouteRoute,
|
||||
organizationBillingPageRouteRoute: organizationBillingPageRouteRoute,
|
||||
organizationNetworkingPageRouteRoute: organizationNetworkingPageRouteRoute,
|
||||
organizationProjectsPageRouteRoute: organizationProjectsPageRouteRoute,
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRoute:
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren,
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRoute:
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteWithChildren,
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute:
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren,
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRoute:
|
||||
@@ -5055,6 +5021,7 @@ export interface FileRoutesByFullPath {
|
||||
'/organization/access-management': typeof organizationAccessManagementPageRouteRoute
|
||||
'/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute
|
||||
'/organization/billing': typeof organizationBillingPageRouteRoute
|
||||
'/organization/networking': typeof organizationNetworkingPageRouteRoute
|
||||
'/organization/projects': typeof organizationProjectsPageRouteRoute
|
||||
'/admin/access-management': typeof adminAccessManagementPageRouteRoute
|
||||
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
|
||||
@@ -5063,12 +5030,10 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/environment': typeof adminEnvironmentPageRouteRoute
|
||||
'/admin/integrations': typeof adminIntegrationsPageRouteRoute
|
||||
'/organization/app-connections': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren
|
||||
'/organization/networking': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteWithChildren
|
||||
'/organization/secret-sharing': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren
|
||||
'/organization/settings': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRouteWithChildren
|
||||
'/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren
|
||||
'/organization/app-connections/': typeof organizationAppConnectionsAppConnectionsPageRouteRoute
|
||||
'/organization/networking/': typeof organizationNetworkingPageRouteRoute
|
||||
'/organization/secret-sharing/': typeof organizationSecretSharingPageRouteRoute
|
||||
'/organization/settings/': typeof organizationSettingsPageRouteRoute
|
||||
'/organization/groups/$groupId': typeof organizationGroupDetailsByIDPageRouteRoute
|
||||
@@ -5292,6 +5257,7 @@ export interface FileRoutesByTo {
|
||||
'/organization/access-management': typeof organizationAccessManagementPageRouteRoute
|
||||
'/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute
|
||||
'/organization/billing': typeof organizationBillingPageRouteRoute
|
||||
'/organization/networking': typeof organizationNetworkingPageRouteRoute
|
||||
'/organization/projects': typeof organizationProjectsPageRouteRoute
|
||||
'/admin/access-management': typeof adminAccessManagementPageRouteRoute
|
||||
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
|
||||
@@ -5301,7 +5267,6 @@ export interface FileRoutesByTo {
|
||||
'/admin/integrations': typeof adminIntegrationsPageRouteRoute
|
||||
'/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren
|
||||
'/organization/app-connections': typeof organizationAppConnectionsAppConnectionsPageRouteRoute
|
||||
'/organization/networking': typeof organizationNetworkingPageRouteRoute
|
||||
'/organization/secret-sharing': typeof organizationSecretSharingPageRouteRoute
|
||||
'/organization/settings': typeof organizationSettingsPageRouteRoute
|
||||
'/organization/groups/$groupId': typeof organizationGroupDetailsByIDPageRouteRoute
|
||||
@@ -5527,6 +5492,7 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/access-management': typeof organizationAccessManagementPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/billing': typeof organizationBillingPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking': typeof organizationNetworkingPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/projects': typeof organizationProjectsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/access-management': typeof adminAccessManagementPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/authentication': typeof adminAuthenticationPageRouteRoute
|
||||
@@ -5535,12 +5501,10 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/environment': typeof adminEnvironmentPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/integrations': typeof adminIntegrationsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/app-connections': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/settings': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/app-connections/': typeof organizationAppConnectionsAppConnectionsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/networking/': typeof organizationNetworkingPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/': typeof organizationSecretSharingPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/settings/': typeof organizationSettingsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId': typeof organizationGroupDetailsByIDPageRouteRoute
|
||||
@@ -5776,6 +5740,7 @@ export interface FileRouteTypes {
|
||||
| '/organization/access-management'
|
||||
| '/organization/audit-logs'
|
||||
| '/organization/billing'
|
||||
| '/organization/networking'
|
||||
| '/organization/projects'
|
||||
| '/admin/access-management'
|
||||
| '/admin/authentication'
|
||||
@@ -5784,12 +5749,10 @@ export interface FileRouteTypes {
|
||||
| '/admin/environment'
|
||||
| '/admin/integrations'
|
||||
| '/organization/app-connections'
|
||||
| '/organization/networking'
|
||||
| '/organization/secret-sharing'
|
||||
| '/organization/settings'
|
||||
| '/secret-manager/$projectId'
|
||||
| '/organization/app-connections/'
|
||||
| '/organization/networking/'
|
||||
| '/organization/secret-sharing/'
|
||||
| '/organization/settings/'
|
||||
| '/organization/groups/$groupId'
|
||||
@@ -6012,6 +5975,7 @@ export interface FileRouteTypes {
|
||||
| '/organization/access-management'
|
||||
| '/organization/audit-logs'
|
||||
| '/organization/billing'
|
||||
| '/organization/networking'
|
||||
| '/organization/projects'
|
||||
| '/admin/access-management'
|
||||
| '/admin/authentication'
|
||||
@@ -6021,7 +5985,6 @@ export interface FileRouteTypes {
|
||||
| '/admin/integrations'
|
||||
| '/secret-manager/$projectId'
|
||||
| '/organization/app-connections'
|
||||
| '/organization/networking'
|
||||
| '/organization/secret-sharing'
|
||||
| '/organization/settings'
|
||||
| '/organization/groups/$groupId'
|
||||
@@ -6245,6 +6208,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/access-management'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/audit-logs'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/billing'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/networking'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/projects'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/access-management'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/authentication'
|
||||
@@ -6253,12 +6217,10 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/environment'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/integrations'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/app-connections'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/networking'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/settings'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/networking/'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/settings/'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId'
|
||||
@@ -6701,9 +6663,9 @@ export const routeTree = rootRoute
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/access-management",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/audit-logs",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/billing",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/projects",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/settings",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId",
|
||||
@@ -6742,6 +6704,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "organization/BillingPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking": {
|
||||
"filePath": "organization/NetworkingPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/projects": {
|
||||
"filePath": "organization/ProjectsPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization"
|
||||
@@ -6778,13 +6744,6 @@ export const routeTree = rootRoute
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback"
|
||||
]
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking": {
|
||||
"filePath": "",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization",
|
||||
"children": [
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking/"
|
||||
]
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing": {
|
||||
"filePath": "",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization",
|
||||
@@ -6811,10 +6770,6 @@ export const routeTree = rootRoute
|
||||
"filePath": "organization/AppConnections/AppConnectionsPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization/app-connections"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/networking/": {
|
||||
"filePath": "organization/NetworkingPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization/networking"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/": {
|
||||
"filePath": "organization/SecretSharingPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing"
|
||||
|
||||
@@ -37,7 +37,7 @@ const organizationRoutes = route("/organization", [
|
||||
"organization/AppConnections/OauthCallbackPage/route.tsx"
|
||||
)
|
||||
]),
|
||||
route("/networking", [index("organization/NetworkingPage/route.tsx")])
|
||||
route("/networking", "organization/NetworkingPage/route.tsx")
|
||||
]);
|
||||
|
||||
const secretManagerRoutes = route("/projects/secret-management/$projectId", [
|
||||
|
||||
Reference in New Issue
Block a user