mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 14:42:51 +00:00
Merge remote-tracking branch 'origin/main' into feat/blockDuplicateSyncDestination
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user