mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 14:27:23 +00:00
Merge branch 'main' into feat/showWarningOnImportedSecretDeletion
This commit is contained in:
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
|
||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||
We've developed an improved privilege management system to better serve your
|
||||
security needs. Upgrade to our new permission-based approach that allows you to
|
||||
explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons.
|
||||
explicitly designate who can modify specific access levels, rather than relying on
|
||||
hierarchy comparisons.
|
||||
</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
|
||||
+18
-2
@@ -16,6 +16,8 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
|
||||
import { GcpConnectionForm } from "./GcpConnectionForm";
|
||||
import { GitHubConnectionForm } from "./GitHubConnectionForm";
|
||||
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
|
||||
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
|
||||
import { PostgresConnectionForm } from "./PostgresConnectionForm";
|
||||
|
||||
type FormProps = {
|
||||
onComplete: (appConnection: TAppConnection) => void;
|
||||
@@ -31,7 +33,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
|
||||
const { name: appName } = APP_CONNECTION_MAP[app];
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
|
||||
formData: DiscriminativePick<
|
||||
TAppConnection,
|
||||
"method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
const connection = await createAppConnection.mutateAsync(formData);
|
||||
@@ -65,6 +70,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
|
||||
return <DatabricksConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Humanitec:
|
||||
return <HumanitecConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Postgres:
|
||||
return <PostgresConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.MsSql:
|
||||
return <MsSqlConnectionForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled App ${app}`);
|
||||
}
|
||||
@@ -75,7 +84,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
|
||||
formData: DiscriminativePick<
|
||||
TAppConnection,
|
||||
"method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
const connection = await updateAppConnection.mutateAsync({
|
||||
@@ -112,6 +124,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Humanitec:
|
||||
return <HumanitecConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Postgres:
|
||||
return <PostgresConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.MsSql:
|
||||
return <MsSqlConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
default:
|
||||
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import {
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAwsConnection;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
||||
|
||||
type Props = {
|
||||
appConnection?: TDatabricksConnection;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import {
|
||||
|
||||
type Props = {
|
||||
appConnection?: TGcpConnection;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ export const GenericAppConnectionsFields = () => {
|
||||
isOptional
|
||||
>
|
||||
<TextArea
|
||||
className="h-20 !resize-none"
|
||||
className="!resize-none"
|
||||
rows={1}
|
||||
placeholder="Connection description..."
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
+3
-3
@@ -21,7 +21,7 @@ import {
|
||||
|
||||
type Props = {
|
||||
appConnection?: THumanitecConnection;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
@@ -30,7 +30,7 @@ const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
rootSchema.extend({
|
||||
method: z.literal(HumanitecConnectionMethod.API_TOKEN),
|
||||
method: z.literal(HumanitecConnectionMethod.ApiToken),
|
||||
credentials: z.object({
|
||||
apiToken: z.string().trim().min(1, "Service API Token required")
|
||||
})
|
||||
@@ -46,7 +46,7 @@ export const HumanitecConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.Humanitec,
|
||||
method: HumanitecConnectionMethod.API_TOKEN
|
||||
method: HumanitecConnectionMethod.ApiToken
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import {
|
||||
MsSqlConnectionMethod,
|
||||
TMsSqlConnection
|
||||
} from "@app/hooks/api/appConnections/types/mssql-connection";
|
||||
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
|
||||
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
import {
|
||||
BaseSqlUsernameAndPasswordConnectionSchema,
|
||||
PlatformManagedNoticeBanner,
|
||||
SqlConnectionFields
|
||||
} from "./shared";
|
||||
|
||||
type Props = {
|
||||
appConnection?: TMsSqlConnection;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.MsSql),
|
||||
isPlatformManagedCredentials: z.boolean().optional()
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
rootSchema.extend({
|
||||
method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
|
||||
credentials: BaseSqlUsernameAndPasswordConnectionSchema
|
||||
})
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.MsSql,
|
||||
method: MsSqlConnectionMethod.UsernameAndPassword,
|
||||
credentials: {
|
||||
host: "",
|
||||
port: 1433,
|
||||
database: "default",
|
||||
username: "",
|
||||
password: "",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
|
||||
|
||||
const confirmSubmit = async (formData: FormData) => {
|
||||
if (formData.isPlatformManagedCredentials) {
|
||||
setShowConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
setSelectedTabIndex(0);
|
||||
handleSubmit(confirmSubmit)(e);
|
||||
}}
|
||||
>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText={`The method you would like to use to connect with ${
|
||||
APP_CONNECTION_MAP[AppConnection.MsSql].name
|
||||
}. This field cannot be changed after creation.`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(MsSqlConnectionMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} key={method}>
|
||||
{getAppConnectionMethodDetails(method).name}{" "}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<SqlConnectionFields
|
||||
isPlatformManagedCredentials={isPlatformManagedCredentials}
|
||||
selectedTabIndex={selectedTabIndex}
|
||||
setSelectedTabIndex={setSelectedTabIndex}
|
||||
/>
|
||||
{isPlatformManagedCredentials ? (
|
||||
<PlatformManagedNoticeBanner />
|
||||
) : (
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Connect to Database"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<PlatformManagedConfirmationModal
|
||||
onConfirm={() => handleSubmit(onSubmit)()}
|
||||
onOpenChange={setShowConfirmation}
|
||||
isOpen={showConfirmation}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { PostgresConnectionMethod, TPostgresConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
|
||||
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
import {
|
||||
BaseSqlUsernameAndPasswordConnectionSchema,
|
||||
PlatformManagedNoticeBanner,
|
||||
SqlConnectionFields
|
||||
} from "./shared";
|
||||
|
||||
type Props = {
|
||||
appConnection?: TPostgresConnection;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.Postgres),
|
||||
isPlatformManagedCredentials: z.boolean().optional()
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
rootSchema.extend({
|
||||
method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
|
||||
credentials: BaseSqlUsernameAndPasswordConnectionSchema
|
||||
})
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.Postgres,
|
||||
method: PostgresConnectionMethod.UsernameAndPassword,
|
||||
credentials: {
|
||||
host: "",
|
||||
port: 5432,
|
||||
database: "default",
|
||||
username: "",
|
||||
password: "",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
|
||||
|
||||
const confirmSubmit = async (formData: FormData) => {
|
||||
if (formData.isPlatformManagedCredentials) {
|
||||
setShowConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
setSelectedTabIndex(0);
|
||||
handleSubmit(confirmSubmit)(e);
|
||||
}}
|
||||
>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText={`The method you would like to use to connect with ${
|
||||
APP_CONNECTION_MAP[AppConnection.Postgres].name
|
||||
}. This field cannot be changed after creation.`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(PostgresConnectionMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} key={method}>
|
||||
{getAppConnectionMethodDetails(method).name}{" "}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<SqlConnectionFields
|
||||
isPlatformManagedCredentials={isPlatformManagedCredentials}
|
||||
selectedTabIndex={selectedTabIndex}
|
||||
setSelectedTabIndex={setSelectedTabIndex}
|
||||
/>
|
||||
{isPlatformManagedCredentials ? (
|
||||
<PlatformManagedNoticeBanner />
|
||||
) : (
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Connect to Database"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<PlatformManagedConfirmationModal
|
||||
onConfirm={() => handleSubmit(onSubmit)()}
|
||||
onOpenChange={setShowConfirmation}
|
||||
isOpen={showConfirmation}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export const PlatformManagedConfirmationModal = ({ isOpen, onOpenChange, onConfirm }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent className="max-w-2xl" title="Platform Managed Credentials">
|
||||
<NoticeBannerV2 title="Are you sure you want have Infisical manage the credentials of this connection?">
|
||||
<p className="my-1 text-sm text-mineshaft-300">
|
||||
Once created, Infisical will update the password of this connection.
|
||||
</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
You will not be able to access the updated password.
|
||||
</p>
|
||||
</NoticeBannerV2>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<ModalClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Grant Infisical Ownership
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
|
||||
export const PlatformManagedNoticeBanner = () => (
|
||||
<NoticeBannerV2 title="Platform Managed Credentials">
|
||||
<p className="text-sm text-bunker-300">
|
||||
This App Connection's credentials are managed by Infisical and cannot be updated.
|
||||
</p>
|
||||
</NoticeBannerV2>
|
||||
);
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
import { FormControl, Input, SecretInput, Switch, TextArea, Tooltip } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
isPlatformManagedCredentials: boolean;
|
||||
selectedTabIndex: number;
|
||||
setSelectedTabIndex: Dispatch<SetStateAction<number>>;
|
||||
};
|
||||
|
||||
export const SqlConnectionFields = ({
|
||||
isPlatformManagedCredentials,
|
||||
setSelectedTabIndex,
|
||||
selectedTabIndex
|
||||
}: Props) => {
|
||||
const { control, watch } = useFormContext();
|
||||
|
||||
const sslEnabled = watch("credentials.sslEnabled");
|
||||
return (
|
||||
<>
|
||||
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
|
||||
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
|
||||
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Configuration
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
|
||||
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
SSL ({sslEnabled ? "Enabled" : "Disabled"})
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
|
||||
<Tab.Panel>
|
||||
<div className="mt-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="credentials.host"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Host"
|
||||
>
|
||||
<Input {...field} isDisabled={isPlatformManagedCredentials} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.database"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Database Name"
|
||||
>
|
||||
<Input {...field} isDisabled={isPlatformManagedCredentials} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.port"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-28"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Port"
|
||||
>
|
||||
<Input type="number" {...field} isDisabled={isPlatformManagedCredentials} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="credentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input {...field} isDisabled={isPlatformManagedCredentials} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.password"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
className="flex-1"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 w-full 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)}
|
||||
isDisabled={isPlatformManagedCredentials}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<Controller
|
||||
name="credentials.sslEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
isDisabled={isPlatformManagedCredentials}
|
||||
>
|
||||
Enable SSL
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.sslCertificate"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
label="SSL Certificate"
|
||||
isOptional
|
||||
>
|
||||
<TextArea
|
||||
className="h-[3.5rem] !resize-none"
|
||||
{...field}
|
||||
isDisabled={isPlatformManagedCredentials || !sslEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.sslRejectUnauthorized"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-reject-unauthorized"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={sslEnabled ? value : false}
|
||||
onCheckedChange={onChange}
|
||||
isDisabled={isPlatformManagedCredentials || !sslEnabled}
|
||||
>
|
||||
<p className="w-[9.5rem]">
|
||||
Reject Unauthorized
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<p>
|
||||
If enabled, Infisical will only connect to the server if it has a valid,
|
||||
trusted SSL certificate.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
{!isPlatformManagedCredentials && (
|
||||
<Controller
|
||||
name="isPlatformManagedCredentials"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="platform-managed"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
isDisabled={isPlatformManagedCredentials}
|
||||
>
|
||||
<p className="w-[13.6rem]">
|
||||
Platform Managed Credentials
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<p>
|
||||
If enabled, Infisical will manage the credentials of this App Connection by
|
||||
updating the password on creation.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./PlatformManagedNoticeBanner";
|
||||
export * from "./sql-connection-schemas";
|
||||
export * from "./SqlConnectionFields";
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({
|
||||
host: z.string().trim().min(1, "Host required"),
|
||||
port: z.coerce.number().default(5432),
|
||||
database: z.string().trim().min(1, "Database required").default("default"),
|
||||
username: z.string().trim().min(1, "Username required"),
|
||||
password: z.string().trim().min(1, "Password required"),
|
||||
sslEnabled: z.boolean().default(true),
|
||||
sslRejectUnauthorized: z.boolean().default(true),
|
||||
sslCertificate: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value || undefined)
|
||||
.optional()
|
||||
});
|
||||
+79
-65
@@ -6,6 +6,7 @@ import {
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faInfoCircle,
|
||||
faServer,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -14,6 +15,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -42,7 +44,7 @@ export const AppConnectionRow = ({
|
||||
onEditCredentials,
|
||||
onEditDetails
|
||||
}: Props) => {
|
||||
const { id, name, method, app, description } = appConnection;
|
||||
const { id, name, method, app, description, isPlatformManagedCredentials } = appConnection;
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
@@ -100,70 +102,82 @@ export const AppConnectionRow = ({
|
||||
</Td>
|
||||
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={() => handleCopyId()}
|
||||
>
|
||||
Copy Connection ID
|
||||
</DropdownMenuItem>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionAppConnectionActions.Edit}
|
||||
a={OrgPermissionSubjects.AppConnections}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={() => onEditDetails(appConnection)}
|
||||
>
|
||||
Edit Details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionAppConnectionActions.Edit}
|
||||
a={OrgPermissionSubjects.AppConnections}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faAsterisk} />}
|
||||
onClick={() => onEditCredentials(appConnection)}
|
||||
>
|
||||
Edit Credentials
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionAppConnectionActions.Delete}
|
||||
a={OrgPermissionSubjects.AppConnections}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={() => onDelete(appConnection)}
|
||||
>
|
||||
Delete Connection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isPlatformManagedCredentials && (
|
||||
<Tooltip side="left" content="This connection's credentials are managed by Infisical.">
|
||||
<div>
|
||||
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap">
|
||||
<FontAwesomeIcon icon={faServer} />
|
||||
<span>Platform Managed Credentials</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={() => handleCopyId()}
|
||||
>
|
||||
Copy Connection ID
|
||||
</DropdownMenuItem>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionAppConnectionActions.Edit}
|
||||
a={OrgPermissionSubjects.AppConnections}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={() => onEditDetails(appConnection)}
|
||||
>
|
||||
Edit Details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionAppConnectionActions.Edit}
|
||||
a={OrgPermissionSubjects.AppConnections}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faAsterisk} />}
|
||||
onClick={() => onEditCredentials(appConnection)}
|
||||
>
|
||||
{isPlatformManagedCredentials ? "View" : "Edit"} Credentials
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionAppConnectionActions.Delete}
|
||||
a={OrgPermissionSubjects.AppConnections}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={() => onDelete(appConnection)}
|
||||
>
|
||||
Delete Connection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
|
||||
+13
-1
@@ -1,5 +1,6 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
|
||||
import { TAppConnection, useDeleteAppConnection } from "@app/hooks/api/appConnections";
|
||||
|
||||
@@ -46,6 +47,17 @@ export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }
|
||||
title={`Are you sure want to delete ${name}?`}
|
||||
deleteKey={name}
|
||||
onDeleteApproved={handleDeleteAppConnection}
|
||||
/>
|
||||
>
|
||||
{appConnection.isPlatformManagedCredentials && (
|
||||
<NoticeBannerV2 className="mt-3" title="Platform Managed Credentials">
|
||||
<p className="text-sm text-bunker-300">
|
||||
This App Connection's credentials are managed by Infisical.
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-bunker-300">
|
||||
By deleting this connection you may lose permanent access to the associated resource.
|
||||
</p>
|
||||
</NoticeBannerV2>
|
||||
)}
|
||||
</DeleteActionModal>
|
||||
);
|
||||
};
|
||||
|
||||
+30
-432
@@ -1,62 +1,21 @@
|
||||
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDownAZ,
|
||||
faArrowRight,
|
||||
faArrowUpZA,
|
||||
faBorderAll,
|
||||
faExclamationCircle,
|
||||
faList,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faStar as faSolidStar
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { NewProjectModal } from "@app/components/projects";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
PageHeader,
|
||||
Pagination,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { getProjectHomePage } from "@app/helpers/project";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { Workspace } from "@app/hooks/api/types";
|
||||
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
|
||||
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
enum ProjectsViewMode {
|
||||
GRID = "grid",
|
||||
LIST = "list"
|
||||
}
|
||||
|
||||
enum ProjectOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
import { AllProjectView } from "./components/AllProjectView";
|
||||
import { MyProjectView } from "./components/MyProjectView";
|
||||
import { ProjectListToggle, ProjectListView } from "./components/ProjectListToggle";
|
||||
|
||||
const formatDescription = (type: ProjectType) => {
|
||||
if (type === ProjectType.SecretManager)
|
||||
@@ -76,295 +35,20 @@ type Props = {
|
||||
export const ProductOverviewPage = ({ type }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: workspaces, isPending: isWorkspaceLoading } = useGetUserWorkspaces({ type });
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgWorkspaces = workspaces || [];
|
||||
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
|
||||
useGetUserProjectFavorites(currentOrg?.id);
|
||||
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
|
||||
|
||||
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
|
||||
const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"addNewWs",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
|
||||
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
|
||||
);
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||
: true;
|
||||
|
||||
const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0;
|
||||
|
||||
const {
|
||||
setPage,
|
||||
perPage,
|
||||
setPerPage,
|
||||
page,
|
||||
offset,
|
||||
limit,
|
||||
toggleOrderDirection,
|
||||
orderDirection
|
||||
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
|
||||
|
||||
const filteredWorkspaces = useMemo(
|
||||
() =>
|
||||
orgWorkspaces
|
||||
.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
.sort((a, b) =>
|
||||
orderDirection === OrderByDirection.ASC
|
||||
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
||||
),
|
||||
[searchFilter, orderDirection, orgWorkspaces]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
setPage,
|
||||
offset,
|
||||
totalCount: filteredWorkspaces.length
|
||||
});
|
||||
|
||||
const { workspacesWithFaveProp } = useMemo(() => {
|
||||
const workspacesWithFav = filteredWorkspaces
|
||||
.map((w): Workspace & { isFavorite: boolean } => ({
|
||||
...w,
|
||||
isFavorite: Boolean(projectFavorites?.includes(w.id))
|
||||
}))
|
||||
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite))
|
||||
.slice(offset, limit * page);
|
||||
|
||||
return {
|
||||
workspacesWithFaveProp: workspacesWithFav
|
||||
};
|
||||
}, [filteredWorkspaces, projectFavorites, offset, limit, page]);
|
||||
|
||||
const addProjectToFavorites = async (projectId: string) => {
|
||||
try {
|
||||
if (currentOrg?.id) {
|
||||
await updateUserProjectFavorites({
|
||||
orgId: currentOrg?.id,
|
||||
projectFavorites: [...(projectFavorites || []), projectId]
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to add project to favorites.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeProjectFromFavorites = async (projectId: string) => {
|
||||
try {
|
||||
if (currentOrg?.id) {
|
||||
await updateUserProjectFavorites({
|
||||
orgId: currentOrg?.id,
|
||||
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to remove project from favorites.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: getProjectHomePage(workspace),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
key={workspace.id}
|
||||
className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
|
||||
{isFavorite ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSolidStar}
|
||||
className="text-sm text-yellow-600 hover:text-mineshaft-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeProjectFromFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faStar}
|
||||
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addProjectToFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2
|
||||
}}
|
||||
>
|
||||
{workspace.description}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-row items-end justify-between place-self-end">
|
||||
{type === ProjectType.SecretManager && (
|
||||
<div className="mt-0 text-xs text-mineshaft-400">
|
||||
{workspace.environments?.length || 0} environments
|
||||
</div>
|
||||
)}
|
||||
<button type="button">
|
||||
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
|
||||
Explore{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: getProjectHomePage(workspace),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
key={workspace.id}
|
||||
className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
||||
index === 0 && "rounded-t-md"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center sm:col-span-3 lg:col-span-4">
|
||||
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
|
||||
<div className="text-center text-sm text-mineshaft-300">
|
||||
{workspace.environments?.length || 0} environments
|
||||
</div>
|
||||
{isFavorite ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSolidStar}
|
||||
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeProjectFromFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faStar}
|
||||
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addProjectToFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
let projectsComponents: ReactNode;
|
||||
|
||||
if (filteredWorkspaces.length || isProjectViewLoading) {
|
||||
switch (projectsViewMode) {
|
||||
case ProjectsViewMode.GRID:
|
||||
projectsComponents = (
|
||||
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{isProjectViewLoading &&
|
||||
Array.apply(0, Array(3)).map((_x, i) => (
|
||||
<div
|
||||
key={`workspace-cards-loading-${i + 1}`}
|
||||
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="mt-0 text-lg text-mineshaft-100">
|
||||
<Skeleton className="w-3/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!isProjectViewLoading && (
|
||||
<>
|
||||
{workspacesWithFaveProp.map((workspace) =>
|
||||
renderProjectGridItem(workspace, workspace.isFavorite)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
break;
|
||||
case ProjectsViewMode.LIST:
|
||||
default:
|
||||
projectsComponents = (
|
||||
<div className="mt-4 w-full rounded-md">
|
||||
{isProjectViewLoading &&
|
||||
Array.apply(0, Array(3)).map((_x, i) => (
|
||||
<div
|
||||
key={`workspace-cards-loading-${i + 1}`}
|
||||
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
||||
i === 0 && "rounded-t-md"
|
||||
} ${i === 2 && "rounded-b-md border-b"}`}
|
||||
>
|
||||
<Skeleton className="w-full bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
{!isProjectViewLoading &&
|
||||
workspacesWithFaveProp.map((workspace, ind) =>
|
||||
renderProjectListItem(workspace, workspace.isFavorite, ind)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else if (orgWorkspaces.length && searchFilter) {
|
||||
projectsComponents = (
|
||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faSearch}
|
||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||
/>
|
||||
<div className="text-center font-light">No projects match search...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
|
||||
<Helmet>
|
||||
@@ -395,116 +79,30 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex flex-col items-start justify-start">
|
||||
<PageHeader title="Projects" description={formatDescription(type)} />
|
||||
<div className="flex w-full flex-row">
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by project name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Tooltip content="Toggle Sort Direction">
|
||||
<IconButton
|
||||
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
|
||||
ariaLabel={`Sort ${
|
||||
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
|
||||
}`}
|
||||
variant="plain"
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
|
||||
setProjectsViewMode(ProjectsViewMode.GRID);
|
||||
}}
|
||||
ariaLabel="grid"
|
||||
size="xs"
|
||||
className={`${
|
||||
projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBorderAll} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
|
||||
setProjectsViewMode(ProjectsViewMode.LIST);
|
||||
}}
|
||||
ariaLabel="list"
|
||||
size="xs"
|
||||
className={`${
|
||||
projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (isAddingProjectsAllowed) {
|
||||
handlePopUpOpen("addNewWs");
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
className="ml-2"
|
||||
>
|
||||
Add New Project
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
{projectsComponents}
|
||||
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
|
||||
<Pagination
|
||||
className={
|
||||
projectsViewMode === ProjectsViewMode.GRID
|
||||
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
||||
: "rounded-b-md border border-mineshaft-600"
|
||||
}
|
||||
perPage={perPage}
|
||||
perPageList={[12, 24, 48, 96]}
|
||||
count={filteredWorkspaces.length}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{isWorkspaceEmpty && (
|
||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faFolderOpen}
|
||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||
/>
|
||||
<div className="text-center font-light">
|
||||
You are not part of any projects in this organization yet. When you are, they will
|
||||
appear here.
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-4">
|
||||
<ProjectListToggle value={projectListView} onChange={setProjectListView} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-center font-light">
|
||||
Create a new project, or ask other organization members to give you necessary
|
||||
permissions.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
description={formatDescription(type)}
|
||||
/>
|
||||
</div>
|
||||
{projectListView === ProjectListView.MyProjects ? (
|
||||
<MyProjectView
|
||||
type={type}
|
||||
onAddNewProject={() => handlePopUpOpen("addNewWs")}
|
||||
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
|
||||
isAddingProjectsAllowed={isAddingProjectsAllowed}
|
||||
/>
|
||||
) : (
|
||||
<AllProjectView
|
||||
type={type}
|
||||
onAddNewProject={() => handlePopUpOpen("addNewWs")}
|
||||
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
|
||||
isAddingProjectsAllowed={isAddingProjectsAllowed}
|
||||
/>
|
||||
)}
|
||||
<NewProjectModal
|
||||
isOpen={popUp.addNewWs.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowDownAZ,
|
||||
faBorderAll,
|
||||
faCheck,
|
||||
faFolderOpen,
|
||||
faList,
|
||||
faMagnifyingGlass,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { getProjectHomePage } from "@app/helpers/project";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api";
|
||||
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
|
||||
|
||||
type Props = {
|
||||
type: ProjectType;
|
||||
onAddNewProject: () => void;
|
||||
onUpgradePlan: () => void;
|
||||
isAddingProjectsAllowed: boolean;
|
||||
};
|
||||
|
||||
type RequestAccessModalProps = {
|
||||
projectId: string;
|
||||
onPopUpToggle: () => void;
|
||||
};
|
||||
|
||||
const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProps) => {
|
||||
const form = useForm<{ note: string }>();
|
||||
|
||||
const requestProjectAccess = useRequestProjectAccess();
|
||||
|
||||
const onFormSubmit = ({ note }: { note: string }) => {
|
||||
if (requestProjectAccess.isPending) return;
|
||||
requestProjectAccess.mutate(
|
||||
{
|
||||
comment: note,
|
||||
projectId
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
createNotification({
|
||||
type: "success",
|
||||
title: "Project Access Request Sent",
|
||||
text: "Project admins will receive an email of your request"
|
||||
});
|
||||
onPopUpToggle();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<FormControl label="Note">
|
||||
<Input {...form.register("note")} />
|
||||
</FormControl>
|
||||
<div className="mt-4 flex items-center">
|
||||
<Button className="mr-4" size="sm" type="submit" isLoading={form.formState.isSubmitting}>
|
||||
Submit Request
|
||||
</Button>
|
||||
<Button colorSchema="secondary" variant="plain" onClick={() => onPopUpToggle()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AllProjectView = ({
|
||||
type,
|
||||
onAddNewProject,
|
||||
onUpgradePlan,
|
||||
isAddingProjectsAllowed
|
||||
}: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchFilter);
|
||||
const {
|
||||
setPage,
|
||||
perPage,
|
||||
setPerPage,
|
||||
page,
|
||||
offset,
|
||||
limit,
|
||||
toggleOrderDirection,
|
||||
orderDirection
|
||||
} = usePagination("name", { initPerPage: 50 });
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
|
||||
"requestAccessConfirmation"
|
||||
] as const);
|
||||
|
||||
const { data: searchedProjects, isPending: isProjectLoading } = useSearchProjects({
|
||||
limit,
|
||||
offset,
|
||||
type,
|
||||
name: debouncedSearch || undefined,
|
||||
orderDirection
|
||||
});
|
||||
|
||||
useResetPageHelper({
|
||||
setPage,
|
||||
offset,
|
||||
totalCount: searchedProjects?.totalCount || 0
|
||||
});
|
||||
const requestedWorkspaceDetails = (popUp.requestAccessConfirmation.data || {}) as Workspace;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="flex-grow" />
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
containerClassName="w-full"
|
||||
placeholder="Search by project name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Tooltip content="Toggle Sort Direction">
|
||||
<IconButton
|
||||
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
|
||||
ariaLabel="Sort asc"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDownAZ} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Tooltip content="Disabled across All Project view.">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
ariaLabel="grid"
|
||||
size="xs"
|
||||
className="min-w-[2.4rem] border-none bg-transparent hover:bg-mineshaft-600"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBorderAll} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
ariaLabel="list"
|
||||
size="xs"
|
||||
className="min-w-[2.4rem] border-none bg-mineshaft-500 hover:bg-mineshaft-600"
|
||||
>
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (isAddingProjectsAllowed) {
|
||||
onAddNewProject();
|
||||
} else {
|
||||
onUpgradePlan();
|
||||
}
|
||||
}}
|
||||
className="ml-2"
|
||||
>
|
||||
Add New Project
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<div className="mt-4 w-full rounded-md">
|
||||
{isProjectLoading &&
|
||||
Array.apply(0, Array(3)).map((_x, i) => (
|
||||
<div
|
||||
key={`workspace-cards-loading-${i + 1}`}
|
||||
className={twMerge(
|
||||
"flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700",
|
||||
i === 0 && "rounded-t-md",
|
||||
i === 2 && "rounded-b-md border-b"
|
||||
)}
|
||||
>
|
||||
<Skeleton className="w-full bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
{!isProjectLoading &&
|
||||
searchedProjects?.projects?.map((workspace) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter" && workspace.isMember) {
|
||||
navigate({
|
||||
to: getProjectHomePage(workspace),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (workspace.isMember) {
|
||||
navigate({
|
||||
to: getProjectHomePage(workspace),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
key={workspace.id}
|
||||
className={twMerge(
|
||||
"group flex min-w-72 grid-cols-6 items-center justify-center border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 py-3 first:rounded-t-md",
|
||||
workspace.isMember ? "cursor-pointer hover:bg-mineshaft-700" : "cursor-default"
|
||||
)}
|
||||
>
|
||||
<div className="w-full items-center">
|
||||
<div className="flex flex-grow items-center">
|
||||
<div className="flex-grow truncate text-sm text-mineshaft-100">
|
||||
{workspace.name}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{workspace.isMember ? (
|
||||
<div className="flex items-center text-center text-sm text-primary">
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
Joined
|
||||
</div>
|
||||
) : (
|
||||
<div className="opacity-0 transition-all group-hover:opacity-100">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpOpen("requestAccessConfirmation", workspace)}
|
||||
>
|
||||
Request Access
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 max-w-lg overflow-hidden text-ellipsis whitespace-nowrap text-xs text-mineshaft-300">
|
||||
{workspace.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!isProjectLoading && Boolean(searchedProjects?.totalCount) && (
|
||||
<Pagination
|
||||
className="rounded-b-md border border-mineshaft-600"
|
||||
perPage={perPage}
|
||||
perPageList={[12, 24, 48, 96]}
|
||||
count={searchedProjects?.totalCount || 0}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isProjectLoading && !searchedProjects?.totalCount && (
|
||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faFolderOpen}
|
||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||
/>
|
||||
<div className="text-center font-light">No Projects Found</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={popUp.requestAccessConfirmation.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("requestAccessConfirmation", isOpen)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Confirm Access Request"
|
||||
subTitle={`Requesting access to project ${requestedWorkspaceDetails?.name}. You may include an optional note for project admins to review your request.`}
|
||||
>
|
||||
<RequestAccessModal
|
||||
onPopUpToggle={() => handlePopUpToggle("requestAccessConfirmation")}
|
||||
projectId={requestedWorkspaceDetails?.id}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDownAZ,
|
||||
faArrowRight,
|
||||
faArrowUpZA,
|
||||
faBorderAll,
|
||||
faList,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faStar as faSolidStar
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { getProjectHomePage } from "@app/helpers/project";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
|
||||
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
|
||||
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
|
||||
|
||||
type Props = {
|
||||
type: ProjectType;
|
||||
onAddNewProject: () => void;
|
||||
onUpgradePlan: () => void;
|
||||
isAddingProjectsAllowed: boolean;
|
||||
};
|
||||
|
||||
enum ProjectOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
enum ProjectsViewMode {
|
||||
GRID = "grid",
|
||||
LIST = "list"
|
||||
}
|
||||
|
||||
export const MyProjectView = ({
|
||||
type,
|
||||
onAddNewProject,
|
||||
onUpgradePlan,
|
||||
isAddingProjectsAllowed
|
||||
}: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: workspaces = [], isPending: isWorkspaceLoading } = useGetUserWorkspaces({
|
||||
type
|
||||
});
|
||||
const {
|
||||
setPage,
|
||||
perPage,
|
||||
setPerPage,
|
||||
page,
|
||||
offset,
|
||||
limit,
|
||||
toggleOrderDirection,
|
||||
orderDirection
|
||||
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
|
||||
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
|
||||
useGetUserProjectFavorites(currentOrg?.id);
|
||||
|
||||
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
|
||||
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
|
||||
);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
|
||||
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
|
||||
const isWorkspaceEmpty = !isProjectViewLoading && workspaces?.length === 0;
|
||||
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
|
||||
|
||||
const filteredWorkspaces = useMemo(
|
||||
() =>
|
||||
workspaces
|
||||
.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
.sort((a, b) =>
|
||||
orderDirection === OrderByDirection.ASC
|
||||
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
||||
),
|
||||
[searchFilter, orderDirection, workspaces]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
setPage,
|
||||
offset,
|
||||
totalCount: filteredWorkspaces.length
|
||||
});
|
||||
|
||||
const { workspacesWithFaveProp } = useMemo(() => {
|
||||
const workspacesWithFav = filteredWorkspaces
|
||||
.map((w): Workspace & { isFavorite: boolean } => ({
|
||||
...w,
|
||||
isFavorite: Boolean(projectFavorites?.includes(w.id))
|
||||
}))
|
||||
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite))
|
||||
.slice(offset, limit * page);
|
||||
|
||||
return {
|
||||
workspacesWithFaveProp: workspacesWithFav
|
||||
};
|
||||
}, [filteredWorkspaces, projectFavorites, offset, limit, page]);
|
||||
|
||||
const addProjectToFavorites = async (projectId: string) => {
|
||||
try {
|
||||
if (currentOrg?.id) {
|
||||
await updateUserProjectFavorites({
|
||||
orgId: currentOrg?.id,
|
||||
projectFavorites: [...(projectFavorites || []), projectId]
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to add project to favorites.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
const removeProjectFromFavorites = async (projectId: string) => {
|
||||
try {
|
||||
if (currentOrg?.id) {
|
||||
await updateUserProjectFavorites({
|
||||
orgId: currentOrg?.id,
|
||||
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to remove project from favorites.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: getProjectHomePage(workspace),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
key={workspace.id}
|
||||
className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
|
||||
{isFavorite ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSolidStar}
|
||||
className="text-sm text-yellow-600 hover:text-mineshaft-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeProjectFromFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faStar}
|
||||
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addProjectToFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2
|
||||
}}
|
||||
>
|
||||
{workspace.description}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-row items-end justify-between place-self-end">
|
||||
{type === ProjectType.SecretManager && (
|
||||
<div className="mt-0 text-xs text-mineshaft-400">
|
||||
{workspace.environments?.length || 0} environments
|
||||
</div>
|
||||
)}
|
||||
<button type="button">
|
||||
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
|
||||
Explore{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: getProjectHomePage(workspace),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
key={workspace.id}
|
||||
className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
||||
index === 0 && "rounded-t-md"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center sm:col-span-3 lg:col-span-4">
|
||||
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
|
||||
{type === ProjectType.SecretManager && (
|
||||
<div className="text-center text-sm text-mineshaft-300">
|
||||
{workspace.environments?.length || 0} environments
|
||||
</div>
|
||||
)}
|
||||
{isFavorite ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSolidStar}
|
||||
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeProjectFromFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faStar}
|
||||
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addProjectToFavorites(workspace.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
let projectsComponents: ReactNode;
|
||||
|
||||
if (filteredWorkspaces.length || isProjectViewLoading) {
|
||||
switch (projectsViewMode) {
|
||||
case ProjectsViewMode.GRID:
|
||||
projectsComponents = (
|
||||
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{isProjectViewLoading &&
|
||||
Array.apply(0, Array(3)).map((_x, i) => (
|
||||
<div
|
||||
key={`workspace-cards-loading-${i + 1}`}
|
||||
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="mt-0 text-lg text-mineshaft-100">
|
||||
<Skeleton className="w-3/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!isProjectViewLoading && (
|
||||
<>
|
||||
{workspacesWithFaveProp.map((workspace) =>
|
||||
renderProjectGridItem(workspace, workspace.isFavorite)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
break;
|
||||
case ProjectsViewMode.LIST:
|
||||
default:
|
||||
projectsComponents = (
|
||||
<div className="mt-4 w-full rounded-md">
|
||||
{isProjectViewLoading &&
|
||||
Array.apply(0, Array(3)).map((_x, i) => (
|
||||
<div
|
||||
key={`workspace-cards-loading-${i + 1}`}
|
||||
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
||||
i === 0 && "rounded-t-md"
|
||||
} ${i === 2 && "rounded-b-md border-b"}`}
|
||||
>
|
||||
<Skeleton className="w-full bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
{!isProjectViewLoading &&
|
||||
workspacesWithFaveProp.map((workspace, ind) =>
|
||||
renderProjectListItem(workspace, workspace.isFavorite, ind)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else if (workspaces.length && searchFilter) {
|
||||
projectsComponents = (
|
||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faSearch}
|
||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||
/>
|
||||
<div className="text-center font-light">No projects match search...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="flex-grow" />
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
containerClassName="w-full"
|
||||
placeholder="Search by project name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Tooltip content="Toggle Sort Direction">
|
||||
<IconButton
|
||||
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
|
||||
ariaLabel={`Sort ${
|
||||
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
|
||||
}`}
|
||||
variant="plain"
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
|
||||
setProjectsViewMode(ProjectsViewMode.GRID);
|
||||
}}
|
||||
ariaLabel="grid"
|
||||
size="xs"
|
||||
className={`${
|
||||
projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBorderAll} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
|
||||
setProjectsViewMode(ProjectsViewMode.LIST);
|
||||
}}
|
||||
ariaLabel="list"
|
||||
size="xs"
|
||||
className={`${
|
||||
projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (isAddingProjectsAllowed) {
|
||||
onAddNewProject();
|
||||
} else {
|
||||
onUpgradePlan();
|
||||
}
|
||||
}}
|
||||
className="ml-2"
|
||||
>
|
||||
Add New Project
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
{projectsComponents}
|
||||
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
|
||||
<Pagination
|
||||
className={
|
||||
projectsViewMode === ProjectsViewMode.GRID
|
||||
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
||||
: "rounded-b-md border border-mineshaft-600"
|
||||
}
|
||||
perPage={perPage}
|
||||
perPageList={[12, 24, 48, 96]}
|
||||
count={filteredWorkspaces.length}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{isWorkspaceEmpty && (
|
||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faFolderOpen}
|
||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||
/>
|
||||
<div className="text-center font-light">
|
||||
You are not part of any projects in this organization yet. When you are, they will
|
||||
appear here.
|
||||
</div>
|
||||
<div className="mt-0.5 text-center font-light">
|
||||
Create a new project, or ask other organization members to give you necessary
|
||||
permissions.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Select, SelectItem } from "@app/components/v2";
|
||||
|
||||
export enum ProjectListView {
|
||||
MyProjects = "my-projects",
|
||||
AllProjects = "all-projects"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: ProjectListView;
|
||||
onChange: (value: ProjectListView) => void;
|
||||
};
|
||||
|
||||
export const ProjectListToggle = ({ value, onChange }: Props) => {
|
||||
const getDisplayText = (listView: ProjectListView) => {
|
||||
return listView === ProjectListView.MyProjects ? "My Projects" : "All Projects";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex cursor-pointer items-center gap-2">
|
||||
<h1 className="text-3xl font-semibold transition-colors group-hover:text-gray-500">
|
||||
{getDisplayText(value)}
|
||||
</h1>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="absolute left-0 top-0 h-full w-full cursor-pointer opacity-0"
|
||||
>
|
||||
<SelectItem value={ProjectListView.MyProjects}>My Projects</SelectItem>
|
||||
<SelectItem value={ProjectListView.AllProjects}>All Projects</SelectItem>
|
||||
</Select>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className="text-lg transition-colors group-hover:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+69
-11
@@ -1,11 +1,20 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
@@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate({ from: "" });
|
||||
const { permission } = useOrgPermission();
|
||||
const requesterEmail = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.requesterEmail
|
||||
});
|
||||
|
||||
const orgId = currentOrg?.id || "";
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
@@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TAddMemberForm>({
|
||||
@@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||
|
||||
useEffect(() => {
|
||||
if (requesterEmail) {
|
||||
handlePopUpToggle("addMember", true);
|
||||
}
|
||||
}, [requesterEmail]);
|
||||
|
||||
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
|
||||
if (!currentWorkspace) return;
|
||||
if (!currentOrg?.id) return;
|
||||
@@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
reset();
|
||||
};
|
||||
|
||||
const filteredOrgUsers = useMemo(() => {
|
||||
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
|
||||
const projectInviteList = useMemo(() => {
|
||||
const wsUserUsernames = new Map();
|
||||
members?.forEach((member) => {
|
||||
wsUserUsernames.set(member.user.username, true);
|
||||
});
|
||||
return (orgUsers || [])
|
||||
const list = (orgUsers || [])
|
||||
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
||||
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
||||
value: id,
|
||||
@@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
? `${firstName} ${lastName}`
|
||||
: firstName || lastName || email || inviteEmail
|
||||
}));
|
||||
const requesterStatus = { isProjectUser: wsUserUsernames.has(requesterEmail), userLabel: "" };
|
||||
if (!requesterStatus.isProjectUser) {
|
||||
const userDetails = orgUsers?.find((el) => el.user.username === requesterEmail);
|
||||
if (userDetails) {
|
||||
const { user } = userDetails;
|
||||
const label =
|
||||
user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.firstName || user.lastName || (user.email as string);
|
||||
requesterStatus.userLabel = label;
|
||||
setValue("orgMemberships", [
|
||||
{
|
||||
value: userDetails.id,
|
||||
label
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return { list, requesterStatus };
|
||||
}, [orgUsers, members]);
|
||||
|
||||
const selectedOrgMemberships = watch("orgMemberships");
|
||||
const selectedRoleSlugs = watch("projectRoleSlugs");
|
||||
|
||||
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
|
||||
|
||||
const canInviteNewMembers = permission.can(
|
||||
OrgPermissionActions.Create,
|
||||
OrgPermissionSubjects.Member
|
||||
@@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen)
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, requesterEmail: "" })
|
||||
});
|
||||
handlePopUpToggle("addMember", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
@@ -185,7 +231,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
noOptionsMessage={() => (
|
||||
<>
|
||||
<p>
|
||||
{!filteredOrgUsers.length && (
|
||||
{!projectInviteList.list.length && (
|
||||
<p>All organization members are already assigned to this project.</p>
|
||||
)}
|
||||
</p>
|
||||
@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
placeholder="Add one or more users..."
|
||||
isMulti
|
||||
name="members"
|
||||
options={filteredOrgUsers}
|
||||
options={projectInviteList.list}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
@@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
placeholder="Add one or more users..."
|
||||
isMulti
|
||||
name="members"
|
||||
options={filteredOrgUsers}
|
||||
options={projectInviteList.list}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
@@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlugs"
|
||||
@@ -263,6 +308,19 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{requesterEmail && projectInviteList.requesterStatus.isProjectUser && (
|
||||
<Alert hideTitle variant="danger">
|
||||
<AlertDescription>Requested user is part of the project.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{requesterEmail && !projectInviteList.requesterStatus.isProjectUser && (
|
||||
<Alert hideTitle>
|
||||
<AlertDescription>
|
||||
Assign a role to provide access to requesting user{" "}
|
||||
<b>{projectInviteList.requesterStatus.userLabel}</b>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
import { AccessControlPage } from "./AccessControlPage";
|
||||
|
||||
const AccessControlPageQuerySchema = z.object({
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
|
||||
requesterEmail: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
||||
)({
|
||||
component: AccessControlPage,
|
||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
import { AccessControlPage } from "./AccessControlPage";
|
||||
|
||||
const AccessControlPageQuerySchema = z.object({
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
|
||||
requesterEmail: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
||||
)({
|
||||
component: AccessControlPage,
|
||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
import { AccessControlPage } from "./AccessControlPage";
|
||||
|
||||
const AccessControlPageQuerySchema = z.object({
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
|
||||
requesterEmail: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
||||
)({
|
||||
component: AccessControlPage,
|
||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ type Props = {
|
||||
type:
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports;
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.SecretRotation;
|
||||
};
|
||||
|
||||
export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: Props) => {
|
||||
|
||||
+51
-5
@@ -17,6 +17,7 @@ import {
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionMemberActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretRotationActions,
|
||||
ProjectPermissionSecretSyncActions,
|
||||
TPermissionCondition,
|
||||
TPermissionConditionOperators
|
||||
@@ -66,6 +67,15 @@ const SecretSyncPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretSyncActions.RemoveSecrets]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretRotationPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretRotationActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretRotationActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretRotationActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretRotationActions.Delete]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretRotationActions.RotateSecrets]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const KmipPolicyActionSchema = z.object({
|
||||
[ProjectPermissionKmipActions.ReadClients]: z.boolean().optional(),
|
||||
[ProjectPermissionKmipActions.CreateClients]: z.boolean().optional(),
|
||||
@@ -209,7 +219,12 @@ export const projectRoleFormSchema = z.object({
|
||||
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretRotation]: SecretRotationPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]),
|
||||
@@ -226,6 +241,7 @@ type TConditionalFields =
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| ProjectPermissionSub.SecretRotation
|
||||
| ProjectPermissionSub.Identity;
|
||||
|
||||
export const isConditionalSubjects = (
|
||||
@@ -235,6 +251,7 @@ export const isConditionalSubjects = (
|
||||
subject === ProjectPermissionSub.DynamicSecrets ||
|
||||
subject === ProjectPermissionSub.SecretImports ||
|
||||
subject === ProjectPermissionSub.SecretFolders ||
|
||||
subject === ProjectPermissionSub.SecretRotation ||
|
||||
subject === ProjectPermissionSub.Identity;
|
||||
|
||||
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
||||
@@ -298,6 +315,30 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
if (isConditionalSubjects(subject)) {
|
||||
if (!formVal[subject]) formVal[subject] = [];
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretRotation) {
|
||||
const canRead = action.includes(ProjectPermissionSecretRotationActions.Read);
|
||||
const canReadCredentials = action.includes(
|
||||
ProjectPermissionSecretRotationActions.ReadGeneratedCredentials
|
||||
);
|
||||
const canEdit = action.includes(ProjectPermissionSecretRotationActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionSecretRotationActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionSecretRotationActions.Create);
|
||||
const canRotate = action.includes(ProjectPermissionSecretRotationActions.RotateSecrets);
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
formVal[subject]!.push({
|
||||
[ProjectPermissionSecretRotationActions.Read]: canRead,
|
||||
[ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: canReadCredentials,
|
||||
[ProjectPermissionSecretRotationActions.Create]: canCreate,
|
||||
[ProjectPermissionSecretRotationActions.Edit]: canEdit,
|
||||
[ProjectPermissionSecretRotationActions.Delete]: canDelete,
|
||||
[ProjectPermissionSecretRotationActions.RotateSecrets]: canRotate,
|
||||
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
|
||||
inverted
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.DynamicSecrets) {
|
||||
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
|
||||
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
|
||||
@@ -890,10 +931,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.SecretRotation]: {
|
||||
title: "Secret Rotation",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
{ label: "Read Config", value: ProjectPermissionSecretRotationActions.Read },
|
||||
{
|
||||
label: "Read Generated Credentials",
|
||||
value: ProjectPermissionSecretRotationActions.ReadGeneratedCredentials
|
||||
},
|
||||
{ label: "Create Config", value: ProjectPermissionSecretRotationActions.Create },
|
||||
{ label: "Modify Config", value: ProjectPermissionSecretRotationActions.Edit },
|
||||
{ label: "Remove Config", value: ProjectPermissionSecretRotationActions.Delete },
|
||||
{ label: "Rotate Secrets", value: ProjectPermissionSecretRotationActions.RotateSecrets }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretRollback]: {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
faFolderPlus,
|
||||
faKey,
|
||||
faList,
|
||||
faPlus
|
||||
faPlus,
|
||||
faRotate
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
||||
@@ -23,6 +24,11 @@ import { twMerge } from "tailwind-merge";
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
|
||||
import { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal";
|
||||
import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal";
|
||||
import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal";
|
||||
import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -57,6 +63,7 @@ import {
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
@@ -71,9 +78,16 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
|
||||
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
|
||||
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
|
||||
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
|
||||
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
|
||||
import {
|
||||
useDynamicSecretOverview,
|
||||
useFolderOverview,
|
||||
useSecretOverview,
|
||||
useSecretRotationOverview
|
||||
} from "@app/hooks/utils";
|
||||
import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow";
|
||||
|
||||
import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm";
|
||||
import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm";
|
||||
@@ -99,7 +113,8 @@ enum RowType {
|
||||
Folder = "folder",
|
||||
DynamicSecret = "dynamic",
|
||||
Secret = "secret",
|
||||
Import = "import"
|
||||
Import = "import",
|
||||
SecretRotation = "rotation"
|
||||
}
|
||||
|
||||
type Filter = {
|
||||
@@ -110,7 +125,8 @@ const DEFAULT_FILTER_STATE = {
|
||||
[RowType.Folder]: true,
|
||||
[RowType.DynamicSecret]: true,
|
||||
[RowType.Secret]: true,
|
||||
[RowType.Import]: true
|
||||
[RowType.Import]: true,
|
||||
[RowType.SecretRotation]: true
|
||||
};
|
||||
|
||||
export const OverviewPage = () => {
|
||||
@@ -195,6 +211,15 @@ export const OverviewPage = () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
const userAvailableSecretRotationEnvs = userAvailableEnvs.filter((env) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretRotationActions.Create,
|
||||
subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: env.slug,
|
||||
secretPath
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
|
||||
|
||||
@@ -224,6 +249,7 @@ export const OverviewPage = () => {
|
||||
includeDynamicSecrets: filter.dynamic,
|
||||
includeSecrets: filter.secret,
|
||||
includeImports: filter.import,
|
||||
includeSecretRotations: filter.rotation,
|
||||
search: debouncedSearchFilter,
|
||||
limit,
|
||||
offset
|
||||
@@ -235,15 +261,18 @@ export const OverviewPage = () => {
|
||||
secrets,
|
||||
folders,
|
||||
dynamicSecrets,
|
||||
secretRotations,
|
||||
totalFolderCount,
|
||||
totalSecretCount,
|
||||
totalDynamicSecretCount,
|
||||
totalSecretRotationCount,
|
||||
totalImportCount,
|
||||
totalCount = 0,
|
||||
totalUniqueFoldersInPage,
|
||||
totalUniqueSecretsInPage,
|
||||
totalUniqueSecretImportsInPage,
|
||||
totalUniqueDynamicSecretsInPage
|
||||
totalUniqueDynamicSecretsInPage,
|
||||
totalUniqueSecretRotationsInPage
|
||||
} = overview ?? {};
|
||||
|
||||
const secretImportsShaped = secretImports
|
||||
@@ -273,6 +302,13 @@ export const OverviewPage = () => {
|
||||
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
|
||||
useDynamicSecretOverview(dynamicSecrets);
|
||||
|
||||
const {
|
||||
secretRotationNames,
|
||||
isSecretRotationPresentInEnv,
|
||||
getSecretRotationByName,
|
||||
getSecretRotationStatusesByName
|
||||
} = useSecretRotationOverview(secretRotations);
|
||||
|
||||
const { secKeys, getEnvSecretKeyCount } = useSecretOverview(
|
||||
secrets?.concat(secretImportsShaped) || []
|
||||
);
|
||||
@@ -301,6 +337,11 @@ export const OverviewPage = () => {
|
||||
"misc",
|
||||
"updateFolder",
|
||||
"addDynamicSecret",
|
||||
"addSecretRotation",
|
||||
"editSecretRotation",
|
||||
"rotateSecretRotation",
|
||||
"viewSecretRotationGeneratedCredentials",
|
||||
"deleteSecretRotation",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
@@ -841,6 +882,21 @@ export const OverviewPage = () => {
|
||||
<span>Dynamic Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleRowType(RowType.SecretRotation);
|
||||
}}
|
||||
icon={
|
||||
filter[RowType.SecretRotation] && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
|
||||
<span>Secret Rotations</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -952,6 +1008,29 @@ export const OverviewPage = () => {
|
||||
Add Dynamic Secret
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
userAvailableSecretRotationEnvs.length === 0 ? "Access restricted" : ""
|
||||
}
|
||||
>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faRotate} className="pr-2" />}
|
||||
onClick={() => {
|
||||
if (subscription?.secretRotation) {
|
||||
handlePopUpOpen("addSecretRotation");
|
||||
handlePopUpClose("misc");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
isDisabled={userAvailableSecretRotationEnvs.length === 0}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Add Secret Rotation
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1141,6 +1220,29 @@ export const OverviewPage = () => {
|
||||
key={`overview-${dynamicSecretName}-${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
{secretRotationNames.map((secretRotationName, index) => (
|
||||
<SecretOverviewSecretRotationRow
|
||||
secretRotationName={secretRotationName}
|
||||
isSecretRotationInEnv={isSecretRotationPresentInEnv}
|
||||
environments={visibleEnvs}
|
||||
getSecretRotationByName={getSecretRotationByName}
|
||||
getSecretRotationStatusesByName={getSecretRotationStatusesByName}
|
||||
key={`overview-${secretRotationName}-${index + 1}`}
|
||||
scrollOffset={scrollOffset}
|
||||
onEdit={(secretRotation) =>
|
||||
handlePopUpOpen("editSecretRotation", secretRotation)
|
||||
}
|
||||
onRotate={(secretRotation) =>
|
||||
handlePopUpOpen("rotateSecretRotation", secretRotation)
|
||||
}
|
||||
onViewGeneratedCredentials={(secretRotation) =>
|
||||
handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation)
|
||||
}
|
||||
onDelete={(secretRotation) =>
|
||||
handlePopUpOpen("deleteSecretRotation", secretRotation)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{secKeys.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
isSelected={Boolean(selectedEntries.secret[key])}
|
||||
@@ -1165,7 +1267,8 @@ export const OverviewPage = () => {
|
||||
(totalUniqueFoldersInPage || 0) -
|
||||
(totalUniqueDynamicSecretsInPage || 0) -
|
||||
(totalUniqueSecretsInPage || 0) -
|
||||
(totalUniqueSecretImportsInPage || 0),
|
||||
(totalUniqueSecretImportsInPage || 0) -
|
||||
(totalUniqueSecretRotationsInPage || 0),
|
||||
0
|
||||
)}
|
||||
/>
|
||||
@@ -1206,6 +1309,7 @@ export const OverviewPage = () => {
|
||||
secretCount={totalSecretCount}
|
||||
folderCount={totalFolderCount}
|
||||
importCount={totalImportCount}
|
||||
secretRotationCount={totalSecretRotationCount}
|
||||
/>
|
||||
}
|
||||
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
||||
@@ -1277,6 +1381,34 @@ export const OverviewPage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<CreateSecretRotationV2Modal
|
||||
secretPath={secretPath}
|
||||
environments={userAvailableSecretRotationEnvs}
|
||||
isOpen={popUp.addSecretRotation.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addSecretRotation", isOpen)}
|
||||
/>
|
||||
<EditSecretRotationV2Modal
|
||||
isOpen={popUp.editSecretRotation.isOpen}
|
||||
secretRotation={popUp.editSecretRotation.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("editSecretRotation", isOpen)}
|
||||
/>
|
||||
<RotateSecretRotationV2Modal
|
||||
isOpen={popUp.rotateSecretRotation.isOpen}
|
||||
secretRotation={popUp.rotateSecretRotation.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("rotateSecretRotation", isOpen)}
|
||||
/>
|
||||
<ViewSecretRotationV2GeneratedCredentialsModal
|
||||
isOpen={popUp.viewSecretRotationGeneratedCredentials.isOpen}
|
||||
secretRotation={popUp.viewSecretRotationGeneratedCredentials.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) =>
|
||||
handlePopUpToggle("viewSecretRotationGeneratedCredentials", isOpen)
|
||||
}
|
||||
/>
|
||||
<DeleteSecretRotationV2Modal
|
||||
isOpen={popUp.deleteSecretRotation.isOpen}
|
||||
secretRotation={popUp.deleteSecretRotation.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deleteSecretRotation", isOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+14
-5
@@ -7,7 +7,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
PasswordGenerator
|
||||
} from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
@@ -226,10 +232,13 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<PasswordGenerator onUsePassword={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAsterisk,
|
||||
faCheck,
|
||||
faClose,
|
||||
faEdit,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faInfoCircle,
|
||||
faRotate,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge";
|
||||
import { Badge, IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
ProjectPermissionSecretRotationActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
|
||||
import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
|
||||
|
||||
type Props = {
|
||||
secretRotationName: string;
|
||||
environments: { name: string; slug: string }[];
|
||||
isSecretRotationInEnv: (name: string, env: string) => boolean;
|
||||
getSecretRotationByName: (slug: string, name: string) => TSecretRotationV2 | undefined;
|
||||
getSecretRotationStatusesByName: (name: string) => (SecretRotationStatus | null)[] | undefined;
|
||||
scrollOffset: number;
|
||||
onEdit: (secretRotation: TSecretRotationV2) => void;
|
||||
onRotate: (secretRotation: TSecretRotationV2) => void;
|
||||
onViewGeneratedCredentials: (secretRotation: TSecretRotationV2) => void;
|
||||
onDelete: (secretRotation: TSecretRotationV2) => void;
|
||||
};
|
||||
|
||||
export const SecretOverviewSecretRotationRow = ({
|
||||
secretRotationName,
|
||||
environments = [],
|
||||
isSecretRotationInEnv,
|
||||
scrollOffset,
|
||||
getSecretRotationByName,
|
||||
getSecretRotationStatusesByName,
|
||||
onEdit,
|
||||
onRotate,
|
||||
onViewGeneratedCredentials,
|
||||
onDelete
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle(false);
|
||||
const [isSecretVisible, setIsSecretVisible] = useToggle();
|
||||
|
||||
const totalCols = environments.length + 1; // secret key row
|
||||
|
||||
const statuses = getSecretRotationStatusesByName(secretRotationName);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr isHoverable isSelectable onClick={setIsExpanded.toggle} className="group">
|
||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||
<div className="flex w-full items-center space-x-5 border-r border-mineshaft-600 py-2.5 pl-5 pr-2">
|
||||
<div className="text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
</div>
|
||||
<div className="flex-1">{secretRotationName}</div>
|
||||
{statuses?.some((status) => status === SecretRotationStatus.Failed) && (
|
||||
<Tooltip className="max-w-sm" content="One or more secrets failed to rotate.">
|
||||
<div>
|
||||
<Badge
|
||||
variant="danger"
|
||||
className="flex h-5 w-min items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
<span>Rotation Failed</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
{environments.map(({ slug }, i) => {
|
||||
const isPresent = isSecretRotationInEnv(secretRotationName, slug);
|
||||
|
||||
return (
|
||||
<Td
|
||||
key={`sec-overview-${slug}-${i + 1}-folder`}
|
||||
className={twMerge(
|
||||
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
|
||||
isPresent ? "text-green-600" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
icon={isPresent ? faCheck : faXmark}
|
||||
/>
|
||||
</div>
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
{isExpanded &&
|
||||
environments.map(({ name: envName, slug }) => {
|
||||
const secretRotation = getSecretRotationByName(slug, secretRotationName);
|
||||
|
||||
if (!secretRotation) return null;
|
||||
|
||||
const { type, secrets, environment, folder, description } = secretRotation;
|
||||
|
||||
const { name: rotationType, image } = SECRET_ROTATION_MAP[type];
|
||||
|
||||
return (
|
||||
<Tr key={`secret-rotation-${slug}-${secretRotationName}`}>
|
||||
<Td
|
||||
colSpan={totalCols}
|
||||
className={`bg-bunker-600 px-0 py-0 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
|
||||
>
|
||||
<div style={getExpandedRowStyle(scrollOffset)} className="ml-2 p-2">
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead className="!border-b">
|
||||
<tr className="h-10">
|
||||
<th
|
||||
style={{ padding: "0.5rem 1rem" }}
|
||||
className="min-table-row min-w-[30vw] !border-r-0"
|
||||
>
|
||||
<div className="flex w-full flex-wrap items-center">
|
||||
<span>{envName}</span>
|
||||
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
|
||||
<img
|
||||
src={`/images/integrations/${image}`}
|
||||
style={{
|
||||
height: "11px",
|
||||
width: "11px"
|
||||
}}
|
||||
alt={`${rotationType} logo`}
|
||||
/>
|
||||
{rotationType}
|
||||
</Tag>
|
||||
{description && (
|
||||
<Tooltip content={description}>
|
||||
<FontAwesomeIcon
|
||||
icon={faInfoCircle}
|
||||
className="text-mineshaft-400"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<div className="absolute right-1 top-0.5 ml-auto mr-1 mt-1 w-min">
|
||||
<div className="flex items-center gap-2">
|
||||
<SecretRotationV2StatusBadge secretRotation={secretRotation} />
|
||||
<Tooltip content={isSecretVisible ? "Hide Values" : "Reveal Values"}>
|
||||
<IconButton
|
||||
ariaLabel={isSecretVisible ? "Hide Values" : "Reveal Values"}
|
||||
variant="plain"
|
||||
size="md"
|
||||
onClick={() => setIsSecretVisible.toggle()}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.ReadGeneratedCredentials}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="View Generated Credentials"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="View generated credentials"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => onViewGeneratedCredentials(secretRotation)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAsterisk} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.RotateSecrets}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Rotate Secrets"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="Rotate secrets"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => onRotate(secretRotation)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.Edit}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="Edit rotation"
|
||||
variant="plain"
|
||||
size="md"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => onEdit(secretRotation)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.Delete}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="Delete rotation"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
onClick={() => onDelete(secretRotation)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="border-t-2 border-mineshaft-600">
|
||||
{secrets.map((secret, index) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="max-w-sm"
|
||||
content={
|
||||
secret
|
||||
? undefined
|
||||
: "You do not have permission to view this secret."
|
||||
}
|
||||
>
|
||||
<tr
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`rotation-secret-${secretRotation.id}-${index}`}
|
||||
className="!last:border-b-0 h-full hover:bg-mineshaft-700"
|
||||
>
|
||||
<td
|
||||
className="flex h-full items-center"
|
||||
style={{ padding: "0.5rem 1rem" }}
|
||||
>
|
||||
<span className={twMerge(!secret && "blur")}>
|
||||
{secret?.key ?? "********"}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="col-span-2 h-full w-full"
|
||||
style={{ padding: "0.5rem 1rem" }}
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{!secret ? (
|
||||
<div className="h-full pl-4 blur">********</div>
|
||||
) : secret.secretValueHidden ? (
|
||||
<Blur
|
||||
className="py-0"
|
||||
tooltipText="You do not have permission to read the value of this secret."
|
||||
/>
|
||||
) : (
|
||||
<InfisicalSecretInput
|
||||
isReadOnly
|
||||
value={secret.value}
|
||||
isVisible={isSecretVisible}
|
||||
secretPath={secretRotation.folder.path}
|
||||
environment={secretRotation.environment.slug}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretOverviewSecretRotationRow } from "./SecretOverviewSecretRotationRow";
|
||||
+7
-5
@@ -53,6 +53,7 @@ type Props = {
|
||||
secretId?: string
|
||||
) => Promise<void>;
|
||||
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
|
||||
isRotatedSecret?: boolean;
|
||||
};
|
||||
|
||||
export const SecretEditRow = ({
|
||||
@@ -68,7 +69,8 @@ export const SecretEditRow = ({
|
||||
environment,
|
||||
secretPath,
|
||||
isVisible,
|
||||
secretId
|
||||
secretId,
|
||||
isRotatedSecret
|
||||
}: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -148,7 +150,7 @@ export const SecretEditRow = ({
|
||||
isOpen={isModalOpen}
|
||||
onClose={toggleModal}
|
||||
title="Do you want to delete the selected secret?"
|
||||
deleteKey="delete"
|
||||
deleteKey={secretName}
|
||||
onDeleteApproved={handleDeleteSecret}
|
||||
/>
|
||||
|
||||
@@ -163,7 +165,7 @@ export const SecretEditRow = ({
|
||||
render={({ field }) => (
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
isReadOnly={isImportedSecret}
|
||||
isReadOnly={isImportedSecret || isRotatedSecret}
|
||||
value={field.value as string}
|
||||
key="secret-input"
|
||||
isVisible={isVisible}
|
||||
@@ -287,13 +289,13 @@ export const SecretEditRow = ({
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete">
|
||||
<Tooltip content={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"}>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
ariaLabel="delete-value"
|
||||
className="h-full"
|
||||
onClick={toggleModal}
|
||||
isDisabled={isDeleting || !isAllowed}
|
||||
isDisabled={isDeleting || !isAllowed || isRotatedSecret}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
|
||||
+15
-8
@@ -2,10 +2,12 @@ import { faCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faAngleDown,
|
||||
faCheck,
|
||||
faCodeBranch,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileImport,
|
||||
faKey,
|
||||
faRotate,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -15,6 +17,7 @@ import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/componen
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
|
||||
|
||||
import { SecretEditRow } from "./SecretEditRow";
|
||||
import SecretRenameRow from "./SecretRenameRow";
|
||||
@@ -149,14 +152,7 @@ export const SecretOverviewTableRow = ({
|
||||
isFormExpanded && "border-b-2 border-mineshaft-500"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="ml-2 p-2"
|
||||
style={{
|
||||
marginLeft: scrollOffset,
|
||||
width: "calc(100vw - 350px)", // 350px accounts for sidebar and margin
|
||||
maxWidth: "1270px" // largest width of table on ultra wide
|
||||
}}
|
||||
>
|
||||
<div className="ml-2 p-2" style={getExpandedRowStyle(scrollOffset)}>
|
||||
<SecretRenameRow
|
||||
secretKey={secretKey}
|
||||
environments={environments}
|
||||
@@ -214,6 +210,16 @@ export const SecretOverviewTableRow = ({
|
||||
<FontAwesomeIcon icon={faFileImport} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{secret?.isRotatedSecret && (
|
||||
<Tooltip content="Rotated Secret">
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{secret?.valueOverride && (
|
||||
<Tooltip content="Personal Override">
|
||||
<FontAwesomeIcon icon={faCodeBranch} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="col-span-2 h-8 w-full">
|
||||
@@ -237,6 +243,7 @@ export const SecretOverviewTableRow = ({
|
||||
onSecretCreate={onSecretCreate}
|
||||
onSecretUpdate={onSecretUpdate}
|
||||
environment={slug}
|
||||
isRotatedSecret={secret?.isRotatedSecret}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+34
-5
@@ -6,7 +6,8 @@ import {
|
||||
faFingerprint,
|
||||
faFolder,
|
||||
faKey,
|
||||
faMagnifyingGlass
|
||||
faMagnifyingGlass,
|
||||
faRotate
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -34,6 +35,7 @@ import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
|
||||
import { WsTag } from "@app/hooks/api/tags/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||
import { QuickSearchSecretRotationItem } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretRotationItem";
|
||||
import { RowType } from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.types";
|
||||
|
||||
import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem";
|
||||
@@ -51,7 +53,11 @@ export type QuickSearchModalProps = {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder;
|
||||
type ResourceType =
|
||||
| RowType.Secret
|
||||
| RowType.DynamicSecret
|
||||
| RowType.Folder
|
||||
| RowType.SecretRotation;
|
||||
|
||||
const Content = ({
|
||||
environments,
|
||||
@@ -67,7 +73,8 @@ const Content = ({
|
||||
const [showFilter, setShowFilter] = useState<Record<ResourceType, boolean>>({
|
||||
[RowType.Secret]: true,
|
||||
[RowType.Folder]: true,
|
||||
[RowType.DynamicSecret]: true
|
||||
[RowType.DynamicSecret]: true,
|
||||
[RowType.SecretRotation]: true
|
||||
});
|
||||
const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length);
|
||||
const { data, isPending } = useGetProjectSecretsQuickSearch(
|
||||
@@ -81,12 +88,13 @@ const Content = ({
|
||||
{ enabled: isEnabled }
|
||||
);
|
||||
|
||||
const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {};
|
||||
const { folders = {}, secrets = {}, dynamicSecrets = {}, secretRotations = {} } = data ?? {};
|
||||
|
||||
const isEmpty =
|
||||
(!showFilter[RowType.Folder] || Object.values(folders).length === 0) &&
|
||||
(!showFilter[RowType.Secret] || Object.values(secrets).length === 0) &&
|
||||
(!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0);
|
||||
(!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0) &&
|
||||
(!showFilter[RowType.SecretRotation] || Object.values(secretRotations).length === 0);
|
||||
|
||||
const handleToggleTag = (tag: string) => {
|
||||
setFilterTags((prev) => {
|
||||
@@ -161,6 +169,19 @@ const Content = ({
|
||||
<span>Dynamic Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleShowType(RowType.SecretRotation);
|
||||
}}
|
||||
icon={showFilter[RowType.SecretRotation] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
|
||||
<span>Secret Rotations</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -240,6 +261,14 @@ const Content = ({
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
{showFilter[RowType.SecretRotation] &&
|
||||
Object.entries(secretRotations).map(([key, secretRotationGroup]) => (
|
||||
<QuickSearchSecretRotationItem
|
||||
key={key}
|
||||
onClose={onClose}
|
||||
secretRotationGroup={secretRotationGroup}
|
||||
/>
|
||||
))}
|
||||
{showFilter[RowType.Secret] &&
|
||||
Object.entries(secrets).map(([key, secretGroup]) => (
|
||||
<QuickSearchSecretItem
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { faChevronRight, faFolder, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||
|
||||
type Props = {
|
||||
secretRotationGroup: TDashboardProjectSecretsQuickSearch["secretRotations"][string];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const QuickSearchSecretRotationItem = ({ secretRotationGroup, onClose }: Props) => {
|
||||
const navigate = useNavigate({
|
||||
from: "/secret-manager/$projectId/overview"
|
||||
});
|
||||
|
||||
const [groupSecretRotation] = secretRotationGroup;
|
||||
|
||||
const handleNavigate = () => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
secretPath: groupSecretRotation.folder.path,
|
||||
search: groupSecretRotation.name
|
||||
})
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<Td className="w-full">
|
||||
<div className="inline-flex max-w-[20rem] flex-col">
|
||||
<span className="truncate">
|
||||
<FontAwesomeIcon className="mr-2 self-center text-mineshaft-400" icon={faRotate} />
|
||||
{groupSecretRotation.name}
|
||||
</span>
|
||||
<span className="text-xs text-mineshaft-400">
|
||||
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
|
||||
<Tooltip className="max-w-7xl" content={groupSecretRotation.folder.path}>
|
||||
<span>{reverseTruncate(groupSecretRotation.folder.path)}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+26
-2
@@ -1,4 +1,10 @@
|
||||
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faFileImport,
|
||||
faFingerprint,
|
||||
faFolder,
|
||||
faKey,
|
||||
faRotate
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
@@ -8,13 +14,15 @@ type Props = {
|
||||
importCount?: number;
|
||||
secretCount?: number;
|
||||
dynamicSecretCount?: number;
|
||||
secretRotationCount?: number;
|
||||
};
|
||||
|
||||
export const SecretTableResourceCount = ({
|
||||
folderCount = 0,
|
||||
dynamicSecretCount = 0,
|
||||
secretCount = 0,
|
||||
importCount = 0
|
||||
importCount = 0,
|
||||
secretRotationCount = 0
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 divide-x divide-mineshaft-500 text-sm text-mineshaft-400">
|
||||
@@ -66,6 +74,22 @@ export const SecretTableResourceCount = ({
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{secretRotationCount > 0 && (
|
||||
<Tooltip
|
||||
className="max-w-sm"
|
||||
content={
|
||||
<p className="whitespace-nowrap text-center">
|
||||
Total secret rotation count{" "}
|
||||
<span className="text-center text-mineshaft-400">(matching filters)</span>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
|
||||
<span>{secretRotationCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{secretCount > 0 && (
|
||||
<Tooltip
|
||||
className="max-w-sm"
|
||||
|
||||
+9
-1
@@ -46,6 +46,9 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
|
||||
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
|
||||
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
|
||||
const isRotatedSecretSelected = Object.values(selectedEntries.secret).some((record) =>
|
||||
Object.values(record).some((secret) => secret.isRotatedSecret)
|
||||
);
|
||||
const selectedCount = selectedFolderCount + selectedKeysCount;
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
@@ -121,7 +124,7 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
})
|
||||
);
|
||||
|
||||
if (entry && canDeleteSecret) {
|
||||
if (entry && canDeleteSecret && !entry.isRotatedSecret) {
|
||||
return [
|
||||
...accum,
|
||||
{
|
||||
@@ -186,6 +189,11 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="ml-1 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||
{isRotatedSecretSelected && (
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
Rotated Secrets will not be affected by action.
|
||||
</span>
|
||||
)}
|
||||
{shouldShowDelete && (
|
||||
<>
|
||||
<Tooltip content={areFoldersSelected ? "Moving folders is not supported" : undefined}>
|
||||
|
||||
+4
-1
@@ -156,7 +156,10 @@ const Content = ({
|
||||
);
|
||||
|
||||
Object.values(secrets).forEach((secretRecord) =>
|
||||
Object.entries(secretRecord).map(([env, secret]) => secretsByEnv[env].push(secret))
|
||||
Object.entries(secretRecord).forEach(([env, secret]) => {
|
||||
if (secret.isRotatedSecret) return;
|
||||
secretsByEnv[env].push(secret);
|
||||
})
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const getExpandedRowStyle = (scrollOffset: number) => ({
|
||||
marginLeft: scrollOffset,
|
||||
width: "calc(100vw - 355px)", // 350px accounts for sidebar and margin
|
||||
maxWidth: "1270px" // largest width of table on ultra-wide
|
||||
});
|
||||
+14
-2
@@ -97,7 +97,13 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<Td className="text-red-600">OLD</Td>
|
||||
<Td>{secretVersion?.secretKey}</Td>
|
||||
<Td>
|
||||
<SecretInput isReadOnly value={secretVersion?.secretValue} />
|
||||
{newVersion?.isRotatedSecret ? (
|
||||
<span className="text-mineshaft-400">
|
||||
Rotated Secret value will not be affected
|
||||
</span>
|
||||
) : (
|
||||
<SecretInput isReadOnly value={secretVersion?.secretValue} />
|
||||
)}
|
||||
</Td>
|
||||
<Td>{secretVersion?.secretComment}</Td>
|
||||
<Td className="flex flex-wrap gap-2">
|
||||
@@ -146,7 +152,13 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<Td className="text-green-600">NEW</Td>
|
||||
<Td>{newVersion?.secretKey}</Td>
|
||||
<Td>
|
||||
<SecretInput isReadOnly value={newVersion?.secretValue} />
|
||||
{newVersion?.isRotatedSecret ? (
|
||||
<span className="text-mineshaft-400">
|
||||
Rotated Secret value will not be affected
|
||||
</span>
|
||||
) : (
|
||||
<SecretInput isReadOnly value={newVersion?.secretValue} />
|
||||
)}
|
||||
</Td>
|
||||
<Td>{newVersion?.secretComment}</Td>
|
||||
<Td className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretRotationActions
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useGetImportedSecretsSingleEnv,
|
||||
@@ -39,6 +42,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView";
|
||||
|
||||
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
|
||||
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
|
||||
@@ -137,6 +141,11 @@ const Page = () => {
|
||||
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const canReadSecretRotations = permission.can(
|
||||
ProjectPermissionSecretRotationActions.Read,
|
||||
subject(ProjectPermissionSub.SecretRotation, { environment, secretPath })
|
||||
);
|
||||
|
||||
const canDoReadRollback = permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SecretRollback
|
||||
@@ -150,7 +159,8 @@ const Page = () => {
|
||||
[RowType.Folder]: true,
|
||||
[RowType.Import]: true,
|
||||
[RowType.DynamicSecret]: true,
|
||||
[RowType.Secret]: true
|
||||
[RowType.Secret]: true,
|
||||
[RowType.SecretRotation]: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,6 +204,7 @@ const Page = () => {
|
||||
viewSecretValue: canReadSecretValue,
|
||||
includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic,
|
||||
includeSecrets: canReadSecret && filter.include.secret,
|
||||
includeSecretRotations: canReadSecretRotations && filter.include.rotation,
|
||||
tags: filter.tags
|
||||
});
|
||||
|
||||
@@ -201,13 +212,15 @@ const Page = () => {
|
||||
imports,
|
||||
folders,
|
||||
dynamicSecrets,
|
||||
secretRotations,
|
||||
secrets,
|
||||
totalImportCount = 0,
|
||||
totalFolderCount = 0,
|
||||
totalDynamicSecretCount = 0,
|
||||
totalSecretCount = 0,
|
||||
totalCount = 0,
|
||||
importedBy
|
||||
importedBy,
|
||||
totalSecretRotationCount = 0
|
||||
} = data ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
@@ -267,7 +280,8 @@ const Page = () => {
|
||||
(imports?.length || 0) -
|
||||
(folders?.length || 0) -
|
||||
(secrets?.length || 0) -
|
||||
(dynamicSecrets?.length || 0),
|
||||
(dynamicSecrets?.length || 0) -
|
||||
(secretRotations?.length || 0),
|
||||
0
|
||||
);
|
||||
const isNotEmpty = Boolean(
|
||||
@@ -275,6 +289,7 @@ const Page = () => {
|
||||
folders?.length ||
|
||||
imports?.length ||
|
||||
dynamicSecrets?.length ||
|
||||
secretRotations?.length ||
|
||||
noAccessSecretCount
|
||||
);
|
||||
|
||||
@@ -364,6 +379,8 @@ const Page = () => {
|
||||
const selectedSecretActions = useSelectedSecretActions();
|
||||
|
||||
const allRowsSelectedOnPage = useMemo(() => {
|
||||
if (!secrets?.length) return { isChecked: false, isIndeterminate: false };
|
||||
|
||||
if (secrets?.every((secret) => selectedSecrets[secret.id]))
|
||||
return { isChecked: true, isIndeterminate: false };
|
||||
|
||||
@@ -499,6 +516,9 @@ const Page = () => {
|
||||
dynamicSecrets={dynamicSecrets}
|
||||
/>
|
||||
)}
|
||||
{canReadSecretRotations && Boolean(secretRotations?.length) && (
|
||||
<SecretRotationListView secretRotations={secretRotations} />
|
||||
)}
|
||||
{canReadSecret && Boolean(secrets?.length) && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
@@ -526,6 +546,7 @@ const Page = () => {
|
||||
importCount={totalImportCount}
|
||||
secretCount={totalSecretCount}
|
||||
folderCount={totalFolderCount}
|
||||
secretRotationCount={totalSecretRotationCount}
|
||||
/>
|
||||
}
|
||||
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
||||
|
||||
@@ -10,5 +10,6 @@ export enum RowType {
|
||||
Folder = "folder",
|
||||
Import = "import",
|
||||
DynamicSecret = "dynamic",
|
||||
Secret = "secret"
|
||||
Secret = "secret",
|
||||
SecretRotation = "rotation"
|
||||
}
|
||||
|
||||
+54
@@ -19,6 +19,7 @@ import {
|
||||
faMinusSquare,
|
||||
faPaste,
|
||||
faPlus,
|
||||
faRotate,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -30,6 +31,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
@@ -133,6 +136,7 @@ export const ActionBar = ({
|
||||
"addDynamicSecret",
|
||||
"addSecretImport",
|
||||
"bulkDeleteSecrets",
|
||||
"addSecretRotation",
|
||||
"moveSecrets",
|
||||
"misc",
|
||||
"upgradePlan",
|
||||
@@ -673,6 +677,23 @@ export const ActionBar = ({
|
||||
<span>Dynamic Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleRowType(RowType.SecretRotation);
|
||||
}}
|
||||
icon={
|
||||
filter?.include[RowType.SecretRotation] && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
|
||||
<span>Secret Rotations</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -856,6 +877,33 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.Create}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment,
|
||||
secretPath
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faRotate} className="pr-2" />}
|
||||
onClick={() => {
|
||||
if (subscription && subscription.secretRotation) {
|
||||
handlePopUpOpen("addSecretRotation");
|
||||
handlePopUpClose("misc");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Secret Rotation
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.SecretImports, {
|
||||
@@ -991,6 +1039,12 @@ export const ActionBar = ({
|
||||
secretPath={secretPath}
|
||||
isSingleEnvironmentMode
|
||||
/>
|
||||
<CreateSecretRotationV2Modal
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
isOpen={popUp.addSecretRotation.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addSecretRotation", isOpen)}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
|
||||
+10
-7
@@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { Button, FormControl, Input, PasswordGenerator } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
@@ -162,12 +162,15 @@ export const CreateSecretForm = ({
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<PasswordGenerator onUsePassword={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+18
-16
@@ -366,7 +366,7 @@ export const SecretDetailSidebar = ({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
isReadOnly={isReadOnly || !isAllowed}
|
||||
isReadOnly={isReadOnly || !isAllowed || secret?.isRotatedSecret}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
key="secret-value"
|
||||
@@ -881,21 +881,23 @@ export const SecretDetailSidebar = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center justify-center ${version === secretVersion.length ? "hidden" : ""}`}
|
||||
>
|
||||
<Tooltip content="Restore Secret Value">
|
||||
<IconButton
|
||||
ariaLabel="Restore"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-md"
|
||||
onClick={() => setValue("value", secretValue)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateRight} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!secret?.isRotatedSecret && (
|
||||
<div
|
||||
className={`flex items-center justify-center ${version === secretVersion.length ? "hidden" : ""}`}
|
||||
>
|
||||
<Tooltip content="Restore Secret Value">
|
||||
<IconButton
|
||||
ariaLabel="Restore"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-md"
|
||||
onClick={() => setValue("value", secretValue)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateRight} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
+36
-20
@@ -48,6 +48,8 @@ import {
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
FontAwesomeSpriteName,
|
||||
formSchema,
|
||||
@@ -91,6 +93,7 @@ export const SecretItem = memo(
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
const { isRotatedSecret } = secret;
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -151,7 +154,6 @@ export const SecretItem = memo(
|
||||
secretTags: selectedTagSlugs
|
||||
})
|
||||
);
|
||||
|
||||
const { secretValueHidden } = secret;
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
@@ -221,29 +223,43 @@ export const SecretItem = memo(
|
||||
<div
|
||||
className={twMerge(
|
||||
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
|
||||
isDirty && "border-primary-400/50"
|
||||
isDirty && "border-primary-400/50",
|
||||
isRotatedSecret && "bg-mineshaft-700/60"
|
||||
)}
|
||||
>
|
||||
<div className="group flex">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-11 w-11 items-center justify-center px-4 py-3",
|
||||
"flex h-11 w-11 items-center justify-center px-4 py-3 text-mineshaft-300",
|
||||
isDirty && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${secret.id}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => onToggleSecretSelect(secret)}
|
||||
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeSymbol
|
||||
className={twMerge(
|
||||
"ml-3 block h-3.5 w-3.5 group-hover:hidden",
|
||||
isSelected && "hidden"
|
||||
)}
|
||||
symbolName={FontAwesomeSpriteName.SecretKey}
|
||||
/>
|
||||
{secret.isRotatedSecret ? (
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faKey} size="xs" className={twMerge("ml-3 h-3.5 w-3.5")} />
|
||||
<FontAwesomeIcon
|
||||
icon={faRotate}
|
||||
size="xs"
|
||||
className="absolute -bottom-[0.05rem] -right-[0.2rem] text-mineshaft-400"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox
|
||||
id={`checkbox-${secret.id}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => onToggleSecretSelect(secret)}
|
||||
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeSymbol
|
||||
className={twMerge(
|
||||
"ml-3 block h-3.5 w-3.5 group-hover:hidden",
|
||||
isSelected && "hidden"
|
||||
)}
|
||||
symbolName={FontAwesomeSpriteName.SecretKey}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
|
||||
<Controller
|
||||
@@ -252,7 +268,7 @@ export const SecretItem = memo(
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
isReadOnly={isReadOnly}
|
||||
isReadOnly={isReadOnly || isRotatedSecret}
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
variant="plain"
|
||||
isDisabled={isOverriden}
|
||||
@@ -294,7 +310,7 @@ export const SecretItem = memo(
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InfisicalSecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
isReadOnly={isReadOnly || isRotatedSecret}
|
||||
key="secret-value"
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
@@ -582,7 +598,7 @@ export const SecretItem = memo(
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"} // just using label for isRotatedSecret, disabled below
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
@@ -592,7 +608,7 @@ export const SecretItem = memo(
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onDeleteSecret(secret)}
|
||||
isDisabled={!isAllowed}
|
||||
isDisabled={!isAllowed || isRotatedSecret}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Close}
|
||||
|
||||
+9
-4
@@ -82,7 +82,8 @@ export const SecretListView = ({
|
||||
skipMultilineEncoding,
|
||||
newKey,
|
||||
secretId,
|
||||
secretMetadata
|
||||
secretMetadata,
|
||||
isRotatedSecret
|
||||
}: Partial<{
|
||||
value: string;
|
||||
comment: string;
|
||||
@@ -93,6 +94,7 @@ export const SecretListView = ({
|
||||
newKey: string;
|
||||
secretId: string;
|
||||
secretMetadata?: { key: string; value: string }[];
|
||||
isRotatedSecret?: boolean;
|
||||
}> = {}
|
||||
) => {
|
||||
if (operation === "delete") {
|
||||
@@ -113,14 +115,16 @@ export const SecretListView = ({
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
...(!isRotatedSecret && {
|
||||
newSecretName: newKey,
|
||||
secretValue: value || ""
|
||||
}),
|
||||
type,
|
||||
tagIds: tags,
|
||||
secretComment: comment,
|
||||
secretReminderRepeatDays: reminderRepeatDays,
|
||||
secretReminderNote: reminderNote,
|
||||
skipMultilineEncoding,
|
||||
newSecretName: newKey,
|
||||
secretMetadata
|
||||
});
|
||||
return;
|
||||
@@ -216,7 +220,8 @@ export const SecretListView = ({
|
||||
secretId: orgSecret.id,
|
||||
newKey: hasKeyChanged ? key : undefined,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding,
|
||||
secretMetadata
|
||||
secretMetadata,
|
||||
isRotatedSecret: orgSecret.isRotatedSecret
|
||||
});
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
+33
-9
@@ -1,3 +1,7 @@
|
||||
import { faLock, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { FontAwesomeSymbol, Input, Tooltip } from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
|
||||
@@ -5,9 +9,10 @@ import { FontAwesomeSpriteName } from "./SecretListView.utils";
|
||||
|
||||
type Props = {
|
||||
count: number;
|
||||
isRotationView?: boolean;
|
||||
};
|
||||
|
||||
export const SecretNoAccessListView = ({ count }: Props) => {
|
||||
export const SecretNoAccessListView = ({ count, isRotationView }: Props) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from(Array(count)).map((_, i) => (
|
||||
@@ -17,15 +22,34 @@ export const SecretNoAccessListView = ({ count }: Props) => {
|
||||
content="You do not have permission to view this secret"
|
||||
key={`no-access-secret-${i + 1}`}
|
||||
>
|
||||
<div className="flex border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex border-b border-mineshaft-600 shadow-none hover:bg-mineshaft-700",
|
||||
isRotationView ? "bg-mineshaft-700/60" : "bg-mineshaft-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center px-4 py-3">
|
||||
<FontAwesomeSymbol
|
||||
className="ml-3 block h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.KeyLock}
|
||||
/>
|
||||
{isRotationView ? (
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
size="xs"
|
||||
className={twMerge("ml-3 h-3.5 w-3.5")}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faRotate}
|
||||
size="xs"
|
||||
className="absolute -bottom-[0.05rem] -right-[0.2rem] text-mineshaft-400"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FontAwesomeSymbol
|
||||
className="ml-3 block h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.KeyLock}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
|
||||
<div className="ml-[0.05rem] flex h-11 w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
isReadOnly
|
||||
@@ -35,7 +59,7 @@ export const SecretNoAccessListView = ({ count }: Props) => {
|
||||
className="w-full px-0 blur-sm placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Blur />
|
||||
<Blur className="pl-8" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { useState } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAsterisk,
|
||||
faClose,
|
||||
faEdit,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faRotate
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge";
|
||||
import { IconButton, Modal, ModalContent, TableContainer, Tag, Tooltip } from "@app/components/v2";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
|
||||
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
|
||||
|
||||
type Props = {
|
||||
secretRotation: TSecretRotationV2;
|
||||
onEdit: () => void;
|
||||
onRotate: () => void;
|
||||
onViewGeneratedCredentials: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export const SecretRotationItem = ({
|
||||
secretRotation,
|
||||
onEdit,
|
||||
onRotate,
|
||||
onViewGeneratedCredentials,
|
||||
onDelete
|
||||
}: Props) => {
|
||||
const { name, type, environment, folder, secrets, description } = secretRotation;
|
||||
|
||||
const { name: rotationType, image } = SECRET_ROTATION_MAP[type];
|
||||
const [showSecrets, setShowSecrets] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={twMerge("group flex border-b border-mineshaft-600 hover:bg-mineshaft-700")}>
|
||||
<div className="text- flex w-11 items-center py-2 pl-5 text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
</div>
|
||||
<div className="flex flex-grow items-center border-r border-mineshaft-600 py-2 pl-4 pr-2">
|
||||
<div className="flex w-full flex-wrap items-center">
|
||||
<span>{name}</span>
|
||||
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
|
||||
<img
|
||||
src={`/images/integrations/${image}`}
|
||||
style={{
|
||||
height: "11px",
|
||||
width: "11px"
|
||||
}}
|
||||
alt={`${rotationType} logo`}
|
||||
/>
|
||||
{rotationType}
|
||||
</Tag>
|
||||
{description && (
|
||||
<Tooltip content={description}>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<SecretRotationV2StatusBadge className="mx-2" secretRotation={secretRotation} />
|
||||
<div
|
||||
key="actions"
|
||||
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
||||
>
|
||||
<Tooltip content="View Rotation Secrets">
|
||||
<IconButton
|
||||
ariaLabel="View rotation secrets"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSecrets(true);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.ReadGeneratedCredentials}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="View Generated Credentials"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="View generated credentials"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
isDisabled={!isAllowed}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewGeneratedCredentials();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAsterisk} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.RotateSecrets}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Rotate Secrets"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="Rotate secrets"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
isDisabled={!isAllowed}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRotate();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="options"
|
||||
className="flex h-10 flex-shrink-0 items-center space-x-1 px-[0.665rem]"
|
||||
initial={{ x: 0, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 10, opacity: 0 }}
|
||||
>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.Edit}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="Edit rotation"
|
||||
variant="plain"
|
||||
size="md"
|
||||
isDisabled={!isAllowed}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretRotationActions.Delete}
|
||||
a={subject(ProjectPermissionSub.SecretRotation, {
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="Delete rotation"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<Modal onOpenChange={setShowSecrets} isOpen={showSecrets}>
|
||||
<ModalContent
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className="max-w-3xl"
|
||||
title="Rotation Secrets"
|
||||
>
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<tbody>
|
||||
{secrets.map((secret, index) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="max-w-sm"
|
||||
content={
|
||||
secret ? undefined : "You do not have permission to view this secret."
|
||||
}
|
||||
>
|
||||
<tr
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`rotation-secret-${secretRotation.id}-${index}`}
|
||||
className="h-full last:!border-b-0 hover:bg-mineshaft-700"
|
||||
>
|
||||
<td className="flex h-full items-center" style={{ padding: "0.5rem 1rem" }}>
|
||||
<span className={twMerge(!secret && "blur")}>
|
||||
{secret?.key ?? "********"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-span-2 h-full w-full" style={{ padding: "0.5rem 1rem" }}>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{!secret ? (
|
||||
<div className="h-full pl-4 blur">********</div>
|
||||
) : secret.secretValueHidden ? (
|
||||
<Blur
|
||||
className="py-0"
|
||||
tooltipText="You do not have permission to read the value of this secret."
|
||||
/>
|
||||
) : (
|
||||
<InfisicalSecretInput
|
||||
isReadOnly
|
||||
value={secret.value}
|
||||
secretPath={secretRotation.folder.path}
|
||||
environment={secretRotation.environment.slug}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal";
|
||||
import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal";
|
||||
import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal";
|
||||
import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
|
||||
|
||||
import { SecretRotationItem } from "./SecretRotationItem";
|
||||
|
||||
type Props = {
|
||||
secretRotations?: TSecretRotationV2[];
|
||||
};
|
||||
|
||||
export const SecretRotationListView = ({ secretRotations }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"editSecretRotation",
|
||||
"rotateSecretRotation",
|
||||
"viewSecretRotationGeneratedCredentials",
|
||||
"deleteSecretRotation"
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<>
|
||||
{secretRotations?.map((secretRotation) => (
|
||||
<SecretRotationItem
|
||||
key={secretRotation.id}
|
||||
secretRotation={secretRotation}
|
||||
onEdit={() => handlePopUpOpen("editSecretRotation", secretRotation)}
|
||||
onRotate={() => handlePopUpOpen("rotateSecretRotation", secretRotation)}
|
||||
onViewGeneratedCredentials={() =>
|
||||
handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation)
|
||||
}
|
||||
onDelete={() => handlePopUpOpen("deleteSecretRotation", secretRotation)}
|
||||
/>
|
||||
))}
|
||||
<EditSecretRotationV2Modal
|
||||
isOpen={popUp.editSecretRotation.isOpen}
|
||||
secretRotation={popUp.editSecretRotation.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("editSecretRotation", isOpen)}
|
||||
/>
|
||||
<RotateSecretRotationV2Modal
|
||||
isOpen={popUp.rotateSecretRotation.isOpen}
|
||||
secretRotation={popUp.rotateSecretRotation.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("rotateSecretRotation", isOpen)}
|
||||
/>
|
||||
<ViewSecretRotationV2GeneratedCredentialsModal
|
||||
isOpen={popUp.viewSecretRotationGeneratedCredentials.isOpen}
|
||||
secretRotation={popUp.viewSecretRotationGeneratedCredentials.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) =>
|
||||
handlePopUpToggle("viewSecretRotationGeneratedCredentials", isOpen)
|
||||
}
|
||||
/>
|
||||
<DeleteSecretRotationV2Modal
|
||||
isOpen={popUp.deleteSecretRotation.isOpen}
|
||||
secretRotation={popUp.deleteSecretRotation.data as TSecretRotationV2}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deleteSecretRotation", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretRotationListView } from "./SecretRotationListView";
|
||||
+9
-2
@@ -76,7 +76,7 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</div>
|
||||
<div className="flex flex-grow items-center space-x-4 px-4 py-3">
|
||||
{mode === "modified" ? (
|
||||
{mode === "modified" && !preSecret?.isRotatedSecret ? (
|
||||
<>
|
||||
<div>{preSecret?.key}</div>
|
||||
<div className="rounded-lg bg-primary px-1 py-0.5 text-xs font-bold text-black">
|
||||
@@ -90,7 +90,14 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
postSecret.key
|
||||
<>
|
||||
{postSecret.key}
|
||||
{postSecret.isRotatedSecret && (
|
||||
<span className="ml-2 text-mineshaft-400">
|
||||
Rotated Secrets are not affected by Rollback
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-3
@@ -98,9 +98,12 @@ export const SnapshotView = ({
|
||||
const doesExist = Boolean(secretGroupById?.[id]);
|
||||
if (doesExist) {
|
||||
diffView.push({
|
||||
mode: deepCompareSecrets(rollSecret, secretGroupById[id])
|
||||
? TDiffModes.NoChange
|
||||
: TDiffModes.Modified,
|
||||
mode:
|
||||
rollSecret.isRotatedSecret ||
|
||||
secretGroupById[id]?.isRotatedSecret ||
|
||||
deepCompareSecrets(rollSecret, secretGroupById[id])
|
||||
? TDiffModes.NoChange
|
||||
: TDiffModes.Modified,
|
||||
pre: secretGroupById[id],
|
||||
post: rollSecret
|
||||
});
|
||||
|
||||
@@ -11,15 +11,19 @@ import {
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
@@ -33,13 +37,9 @@ import {
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteSecretRotation,
|
||||
@@ -47,26 +47,20 @@ import {
|
||||
useGetSecretRotations,
|
||||
useRestartSecretRotation
|
||||
} from "@app/hooks/api";
|
||||
import { TSecretRotationProviderTemplate } from "@app/hooks/api/types";
|
||||
|
||||
import { CreateRotationForm } from "./components/CreateRotationForm";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const Page = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"createRotation",
|
||||
"activeBot",
|
||||
"deleteRotation",
|
||||
"upgradePlan"
|
||||
"upgradePlan",
|
||||
"secretRotationV2"
|
||||
] as const);
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const canCreateRotation = permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SecretRotation
|
||||
);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data: secretRotationProviders, isPending: isRotationProviderLoading } =
|
||||
useGetSecretRotationProviders({ workspaceId });
|
||||
@@ -125,18 +119,6 @@ const Page = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRotation = async (provider: TSecretRotationProviderTemplate) => {
|
||||
if (subscription && !subscription?.secretRotation) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
if (!canCreateRotation) {
|
||||
createNotification({ type: "error", text: "Access permission denied!!" });
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("createRotation", provider);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto w-full max-w-7xl bg-bunker-800 text-white">
|
||||
<PageHeader
|
||||
@@ -157,6 +139,22 @@ const Page = () => {
|
||||
</span>
|
||||
</a>
|
||||
</PageHeader>
|
||||
<NoticeBannerV2 title="Secret Rotations Update">
|
||||
<p className="text-sm text-bunker-200">
|
||||
Infisical is revamping its Secret Rotation experience.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-bunker-200">
|
||||
Secret Rotations can now be created from the{" "}
|
||||
<Link
|
||||
className="text-mineshaft-100 underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
|
||||
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
|
||||
params={{ projectId: currentWorkspace.id }}
|
||||
>
|
||||
Secret Manager Dashboard
|
||||
</Link>{" "}
|
||||
from the actions dropdown.
|
||||
</p>
|
||||
</NoticeBannerV2>
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 mt-6 text-xl font-semibold text-gray-200">Rotated Secrets</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
@@ -241,7 +239,7 @@ const Page = () => {
|
||||
<Td>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
I={ProjectPermissionSecretRotationActions.Edit}
|
||||
a={ProjectPermissionSub.SecretRotation}
|
||||
allowedLabel="Rotate now"
|
||||
renderTooltip
|
||||
@@ -263,7 +261,7 @@ const Page = () => {
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
I={ProjectPermissionSecretRotationActions.Delete}
|
||||
a={ProjectPermissionSub.SecretRotation}
|
||||
allowedLabel="Rotate now"
|
||||
renderTooltip
|
||||
@@ -310,10 +308,12 @@ const Page = () => {
|
||||
key={`infisical-rotation-provider-${provider.name}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handlePopUpOpen("createRotation", provider);
|
||||
onKeyDown={() => {
|
||||
handlePopUpOpen("secretRotationV2", provider.title);
|
||||
}}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("secretRotationV2", provider.title);
|
||||
}}
|
||||
onClick={() => handleCreateRotation(provider)}
|
||||
>
|
||||
<img
|
||||
src={`/images/secretRotation/${provider.image}`}
|
||||
@@ -344,12 +344,6 @@ const Page = () => {
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<CreateRotationForm
|
||||
isOpen={popUp.createRotation.isOpen}
|
||||
workspaceId={workspaceId}
|
||||
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
|
||||
provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRotation.isOpen}
|
||||
title="Are you sure want to delete this rotation?"
|
||||
@@ -363,6 +357,45 @@ const Page = () => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add secret rotation if you switch to Infisical's Team plan."
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.secretRotationV2.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("secretRotationV2", isOpen)}
|
||||
>
|
||||
<ModalContent className="max-w-5xl" title="Secret Rotation Update">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-mineshaft-200">
|
||||
Infisical is revamping its Secret Rotation experience. Navigate to the{" "}
|
||||
<Link
|
||||
className="text-mineshaft-100 underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
|
||||
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
|
||||
params={{ projectId: currentWorkspace.id }}
|
||||
>
|
||||
Secret Manager Dashboard
|
||||
</Link>{" "}
|
||||
to create a Secret Rotation.
|
||||
</p>
|
||||
<div className="overflow-clip rounded border border-mineshaft-600">
|
||||
<img
|
||||
src="/images/secretRotation/secret-rotations-v2-location.png"
|
||||
alt="Secret Rotation V2 location"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/overview` as const,
|
||||
params: { projectId: currentWorkspace.id }
|
||||
})
|
||||
}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Navigate to Secret Manager
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -377,7 +410,7 @@ export const SecretRotationPage = () => {
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Helmet>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Read}
|
||||
I={ProjectPermissionSecretRotationActions.Read}
|
||||
a={ProjectPermissionSub.SecretRotation}
|
||||
passThrough={false}
|
||||
renderGuardBanner
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge } from "@app/components/v2";
|
||||
import { AWS_REGIONS } from "@app/helpers/appConnections";
|
||||
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
|
||||
@@ -16,13 +16,13 @@ export const AwsParameterStoreSyncDestinationSection = ({ secretSync }: Props) =
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncLabel label="Region">
|
||||
<GenericFieldLabel label="Region">
|
||||
{awsRegion?.name}
|
||||
<Badge className="ml-1" variant="success">
|
||||
{awsRegion?.slug}{" "}
|
||||
</Badge>
|
||||
</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Path">{path}</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Path">{path}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge } from "@app/components/v2";
|
||||
import { AWS_REGIONS } from "@app/helpers/appConnections";
|
||||
import {
|
||||
@@ -17,17 +17,17 @@ export const AwsSecretsManagerSyncDestinationSection = ({ secretSync }: Props) =
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncLabel label="Region">
|
||||
<GenericFieldLabel label="Region">
|
||||
{awsRegion?.name}
|
||||
<Badge className="ml-1" variant="success">
|
||||
{awsRegion?.slug}{" "}
|
||||
</Badge>
|
||||
</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Mapping Behavior" className="capitalize">
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Mapping Behavior" className="capitalize">
|
||||
{destinationConfig.mappingBehavior}
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
{destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && (
|
||||
<SecretSyncLabel label="Secret Name">{destinationConfig.secretName}</SecretSyncLabel>
|
||||
<GenericFieldLabel label="Secret Name">{destinationConfig.secretName}</GenericFieldLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TAzureAppConfigurationSync } from "@app/hooks/api/secretSyncs/types/azure-app-configuration-sync";
|
||||
|
||||
type Props = {
|
||||
@@ -12,8 +12,8 @@ export const AzureAppConfigurationSyncDestinationSection = ({ secretSync }: Prop
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncLabel label="Configuration URL">{configurationUrl}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Label">{label}</SecretSyncLabel>
|
||||
<GenericFieldLabel label="Configuration URL">{configurationUrl}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Label">{label}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TAzureKeyVaultSync } from "@app/hooks/api/secretSyncs/types/azure-key-vault-sync";
|
||||
|
||||
type Props = {
|
||||
@@ -10,5 +10,5 @@ export const AzureKeyVaultSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
destinationConfig: { vaultBaseUrl }
|
||||
} = secretSync;
|
||||
|
||||
return <SecretSyncLabel label="Vault URL">{vaultBaseUrl}</SecretSyncLabel>;
|
||||
return <GenericFieldLabel label="Vault URL">{vaultBaseUrl}</GenericFieldLabel>;
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TDatabricksSync } from "@app/hooks/api/secretSyncs/types/databricks-sync";
|
||||
|
||||
type Props = {
|
||||
@@ -10,5 +10,5 @@ export const DatabricksSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
destinationConfig: { scope }
|
||||
} = secretSync;
|
||||
|
||||
return <SecretSyncLabel label="Secret Scope">{scope}</SecretSyncLabel>;
|
||||
return <GenericFieldLabel label="Secret Scope">{scope}</GenericFieldLabel>;
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TGcpSync } from "@app/hooks/api/secretSyncs/types/gcp-sync";
|
||||
|
||||
type Props = {
|
||||
@@ -10,5 +10,5 @@ export const GcpSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
destinationConfig: { projectId }
|
||||
} = secretSync;
|
||||
|
||||
return <SecretSyncLabel label="Project ID">{projectId}</SecretSyncLabel>;
|
||||
return <GenericFieldLabel label="Project ID">{projectId}</GenericFieldLabel>;
|
||||
};
|
||||
|
||||
+13
-13
@@ -2,7 +2,7 @@ import { ReactNode } from "react";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { GitHubSyncSelectedRepositoriesTooltipContent } from "@app/components/secret-syncs/github";
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
@@ -23,12 +23,12 @@ export const GitHubSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
case GitHubSyncScope.Organization:
|
||||
Components = (
|
||||
<>
|
||||
<SecretSyncLabel label="Organization">{destinationConfig.org}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Visibility" className="capitalize">
|
||||
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Visibility" className="capitalize">
|
||||
{destinationConfig.visibility} Repositories
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
{destinationConfig.visibility === GitHubSyncVisibility.Selected && (
|
||||
<SecretSyncLabel label="Selected Repositories">
|
||||
<GenericFieldLabel label="Selected Repositories">
|
||||
{destinationConfig.selectedRepositoryIds?.length ?? 0} Repositories
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
@@ -36,25 +36,25 @@ export const GitHubSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
>
|
||||
<FontAwesomeIcon size="xs" className="ml-1 text-bunker-300" icon={faInfoCircle} />
|
||||
</Tooltip>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case GitHubSyncScope.Repository:
|
||||
Components = (
|
||||
<SecretSyncLabel label="Repository">
|
||||
<GenericFieldLabel label="Repository">
|
||||
{destinationConfig.owner}/{destinationConfig.repo}
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
);
|
||||
break;
|
||||
case GitHubSyncScope.RepositoryEnvironment:
|
||||
Components = (
|
||||
<>
|
||||
<SecretSyncLabel label="Repository">
|
||||
<GenericFieldLabel label="Repository">
|
||||
{destinationConfig.owner}/{destinationConfig.repo}
|
||||
</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Environment">{destinationConfig.env}</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Environment">{destinationConfig.env}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
@@ -66,9 +66,9 @@ export const GitHubSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncLabel className="capitalize" label="Scope">
|
||||
<GenericFieldLabel className="capitalize" label="Scope">
|
||||
{destinationConfig.scope.replace("-", " ")}
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
{Components}
|
||||
</>
|
||||
);
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import {
|
||||
HumanitecSyncScope,
|
||||
THumanitecSync
|
||||
@@ -18,17 +18,17 @@ export const HumanitecSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
case HumanitecSyncScope.Application:
|
||||
Components = (
|
||||
<>
|
||||
<SecretSyncLabel label="Application">{destinationConfig.app}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Organization">{destinationConfig.org}</SecretSyncLabel>
|
||||
<GenericFieldLabel label="Application">{destinationConfig.app}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case HumanitecSyncScope.Environment:
|
||||
Components = (
|
||||
<>
|
||||
<SecretSyncLabel label="Application">{destinationConfig.app}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Organization">{destinationConfig.org}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Environment">{destinationConfig.env}</SecretSyncLabel>
|
||||
<GenericFieldLabel label="Application">{destinationConfig.app}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Environment">{destinationConfig.env}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
@@ -40,9 +40,9 @@ export const HumanitecSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncLabel className="capitalize" label="Scope">
|
||||
<GenericFieldLabel className="capitalize" label="Scope">
|
||||
{destinationConfig.scope.replace("-", " ")}
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
{Components}
|
||||
</>
|
||||
);
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { IconButton } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -83,7 +83,7 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap gap-8">
|
||||
<SecretSyncLabel label={`${app} Connection`}>{connection.name}</SecretSyncLabel>
|
||||
<GenericFieldLabel label={`${app} Connection`}>{connection.name}</GenericFieldLabel>
|
||||
{DestinationComponents}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-9
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretSyncLabel, SecretSyncStatusBadge } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel, SecretSyncStatusBadge } from "@app/components/secret-syncs";
|
||||
import { IconButton } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -55,22 +55,22 @@ export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) =
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<SecretSyncLabel label="Name">{name}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Description">{description}</SecretSyncLabel>
|
||||
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
|
||||
{syncStatus && (
|
||||
<SecretSyncLabel label="Status">
|
||||
<GenericFieldLabel label="Status">
|
||||
<SecretSyncStatusBadge status={syncStatus} />
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
{lastSyncedAt && (
|
||||
<SecretSyncLabel label="Last Synced">
|
||||
<GenericFieldLabel label="Last Synced">
|
||||
{format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
{syncStatus === SecretSyncStatus.Failed && failureMessage && (
|
||||
<SecretSyncLabel labelClassName="text-red" label="Last Sync Error">
|
||||
<GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
|
||||
<p className="break-words rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</p>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { faEye } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
|
||||
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
|
||||
|
||||
@@ -16,9 +16,9 @@ export const AwsParameterStoreSyncOptionsSection = ({ secretSync }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
|
||||
{keyId && <GenericFieldLabel label="KMS Key">{keyId}</GenericFieldLabel>}
|
||||
{tags && tags.length > 0 && (
|
||||
<SecretSyncLabel label="Resource Tags">
|
||||
<GenericFieldLabel label="Resource Tags">
|
||||
<Tooltip
|
||||
side="right"
|
||||
className="max-w-xl p-1"
|
||||
@@ -48,12 +48,12 @@ export const AwsParameterStoreSyncOptionsSection = ({ secretSync }: Props) => {
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
{syncSecretMetadataAsTags && (
|
||||
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
|
||||
<GenericFieldLabel label="Sync Secret Metadata as Resource Tags">
|
||||
<Badge variant="success">Enabled</Badge>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { faEye } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
|
||||
import { TAwsSecretsManagerSync } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync";
|
||||
|
||||
@@ -16,9 +16,9 @@ export const AwsSecretsManagerSyncOptionsSection = ({ secretSync }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
|
||||
{keyId && <GenericFieldLabel label="KMS Key">{keyId}</GenericFieldLabel>}
|
||||
{tags && tags.length > 0 && (
|
||||
<SecretSyncLabel label="Tags">
|
||||
<GenericFieldLabel label="Tags">
|
||||
<Tooltip
|
||||
side="right"
|
||||
className="max-w-xl p-1"
|
||||
@@ -48,12 +48,12 @@ export const AwsSecretsManagerSyncOptionsSection = ({ secretSync }: Props) => {
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
{syncSecretMetadataAsTags && (
|
||||
<SecretSyncLabel label="Sync Secret Metadata as Tags">
|
||||
<GenericFieldLabel label="Sync Secret Metadata as Tags">
|
||||
<Badge variant="success">Enabled</Badge>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge, IconButton } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -78,16 +78,16 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<SecretSyncLabel label="Initial Sync Behavior">
|
||||
<GenericFieldLabel label="Initial Sync Behavior">
|
||||
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name}
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
{/* <SecretSyncLabel label="Prefix">{prependPrefix}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Suffix">{appendSuffix}</SecretSyncLabel> */}
|
||||
{AdditionalSyncOptionsComponent}
|
||||
{disableSecretDeletion && (
|
||||
<SecretSyncLabel label="Secret Deletion">
|
||||
<GenericFieldLabel label="Secret Deletion">
|
||||
<Badge variant="primary">Disabled</Badge>
|
||||
</SecretSyncLabel>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { faEdit, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretSyncLabel } from "@app/components/secret-syncs";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -55,8 +55,8 @@ export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) =>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<SecretSyncLabel label="Environment">{environment?.name}</SecretSyncLabel>
|
||||
<SecretSyncLabel label="Path">{folder?.path}</SecretSyncLabel>
|
||||
<GenericFieldLabel label="Environment">{environment?.name}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Path">{folder?.path}</GenericFieldLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user