Fix merge conflicts

This commit is contained in:
Tuan Dang
2025-04-10 12:39:09 -07:00
443 changed files with 17531 additions and 2266 deletions
@@ -33,14 +33,12 @@ import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/Identity
import { AuthPanel } from "./components/AuthPanel";
import { EncryptionPanel } from "./components/EncryptionPanel";
import { IntegrationPanel } from "./components/IntegrationPanel";
import { RateLimitPanel } from "./components/RateLimitPanel";
import { UserPanel } from "./components/UserPanel";
enum TabSections {
Settings = "settings",
Encryption = "encryption",
Auth = "auth",
RateLimit = "rate-limit",
Integrations = "integrations",
Users = "users",
Identities = "identities",
@@ -163,7 +161,6 @@ export const OverviewPage = () => {
<Tab value={TabSections.Settings}>General</Tab>
<Tab value={TabSections.Encryption}>Encryption</Tab>
<Tab value={TabSections.Auth}>Authentication</Tab>
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>User Identities</Tab>
<Tab value={TabSections.Identities}>Machine Identities</Tab>
@@ -262,7 +259,6 @@ export const OverviewPage = () => {
<SelectClear
selectValue={defaultAuthOrgId}
onClear={() => {
console.log("clearing");
onChange("");
}}
>
@@ -403,9 +399,6 @@ export const OverviewPage = () => {
<TabPanel value={TabSections.Auth}>
<AuthPanel />
</TabPanel>
<TabPanel value={TabSections.RateLimit}>
<RateLimitPanel />
</TabPanel>
<TabPanel value={TabSections.Integrations}>
<IntegrationPanel />
</TabPanel>
@@ -1,240 +0,0 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api";
const formSchema = z.object({
readRateLimit: z.number(),
writeRateLimit: z.number(),
secretsRateLimit: z.number(),
authRateLimit: z.number(),
inviteUserRateLimit: z.number(),
mfaRateLimit: z.number(),
publicEndpointLimit: z.number()
});
type TRateLimitForm = z.infer<typeof formSchema>;
export const RateLimitPanel = () => {
const { data: rateLimit, isPending } = useGetRateLimit();
const { subscription } = useSubscription();
const { mutateAsync: updateRateLimit } = useUpdateRateLimit();
const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const);
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = useForm<TRateLimitForm>({
resolver: zodResolver(formSchema),
values: {
// eslint-disable-next-line
readRateLimit: rateLimit?.readRateLimit ?? 600,
writeRateLimit: rateLimit?.writeRateLimit ?? 200,
secretsRateLimit: rateLimit?.secretsRateLimit ?? 60,
authRateLimit: rateLimit?.authRateLimit ?? 60,
inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30,
mfaRateLimit: rateLimit?.mfaRateLimit ?? 20,
publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30
}
});
const onRateLimitFormSubmit = async (formData: TRateLimitForm) => {
try {
if (subscription && !subscription.customRateLimits) {
handlePopUpOpen("upgradePlan");
return;
}
const {
readRateLimit,
writeRateLimit,
secretsRateLimit,
authRateLimit,
inviteUserRateLimit,
mfaRateLimit,
publicEndpointLimit
} = formData;
await updateRateLimit({
readRateLimit,
writeRateLimit,
secretsRateLimit,
authRateLimit,
inviteUserRateLimit,
mfaRateLimit,
publicEndpointLimit
});
createNotification({
text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update rate limiting setting."
});
}
};
return isPending ? (
<ContentLoader />
) : (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onRateLimitFormSubmit)}
>
<div className="mb-8 flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">Configure rate limits</div>
<Controller
control={control}
name="readRateLimit"
defaultValue={300}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Global read requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="writeRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Global write requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="secretsRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="authRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Auth requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="inviteUserRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User invitation requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="mfaRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Multi factor auth requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="publicEndpointLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret sharing requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting || !isDirty}>
Save
</Button>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can configure custom rate limits if you switch to Infisical's Enterprise plan."
/>
</form>
);
};
@@ -1,7 +1,10 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faFilter,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
@@ -12,14 +15,19 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
FormControl,
IconButton,
Input,
Pagination,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
@@ -30,11 +38,12 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -68,22 +77,22 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { data, isPending, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit,
orderDirection,
orderBy,
search: debouncedSearch
},
{ placeholderData: (prevData) => prevData }
);
const { data, isPending, isFetching } = useSearchIdentities({
offset,
limit,
orderDirection,
orderBy,
search: {
name: debouncedSearch ? { $contains: debouncedSearch } : undefined,
role: filteredRoles?.length ? { $in: filteredRoles } : undefined
}
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
@@ -91,6 +100,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
offset,
setPage
});
const filterForm = useForm<{ roles: string }>();
const { data: roles } = useGetOrgRoles(organizationId);
@@ -132,13 +142,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
return (
<div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div className="mb-4 flex items-center space-x-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div>
<Popover>
<PopoverTrigger>
<IconButton
ariaLabel="filter"
variant="outline_bg"
className={filteredRoles?.length ? "border-primary" : ""}
>
<Tooltip content="Advance Filter">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl">
<div className="mb-4 border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Advance Filter
</div>
<form
onSubmit={filterForm.handleSubmit((el) => {
setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []);
})}
>
<Controller
control={filterForm.control}
name="roles"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Roles"
helperText="Eg: admin,viewer"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
type="submit"
size="xs"
colorSchema="primary"
variant="outline_bg"
className="mt-4"
>
Apply Filter
</Button>
{Boolean(filteredRoles.length) && (
<Button
size="xs"
variant="link"
className="ml-4 mt-4"
onClick={() => {
filterForm.reset({ roles: "" });
setFilteredRoles([]);
}}
>
Clear
</Button>
)}
</div>
</form>
</PopoverContent>
</Popover>
</div>
</div>
<TableContainer>
<Table>
<THead>
@@ -190,7 +265,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-identities" />}
{!isPending &&
data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => {
data?.identities?.map(({ identity: { id, name }, role, customRole }) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
@@ -307,10 +382,10 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
{!isPending && data && data?.identities.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
debouncedSearch.trim().length > 0 || filteredRoles?.length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}
@@ -258,7 +258,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Email
Username
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Email ? "" : "opacity-30"}`}
@@ -478,7 +478,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
if (currentOrg?.scimEnabled && isActive) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
@@ -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}`);
}
@@ -22,7 +22,7 @@ import {
type Props = {
appConnection?: TAwsConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -25,7 +25,7 @@ import {
type Props = {
appConnection?: TDatabricksConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -28,7 +28,7 @@ import {
type Props = {
appConnection?: TGcpConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -32,7 +32,8 @@ export const GenericAppConnectionsFields = () => {
isOptional
>
<TextArea
className="h-20 !resize-none"
className="!resize-none"
rows={1}
placeholder="Connection description..."
{...register("description")}
/>
@@ -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
}
});
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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&#39;s credentials are managed by Infisical and cannot be updated.
</p>
</NoticeBannerV2>
);
@@ -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>
)}
/>
)}
</>
);
};
@@ -0,0 +1,3 @@
export * from "./PlatformManagedNoticeBanner";
export * from "./sql-connection-schemas";
export * from "./SqlConnectionFields";
@@ -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()
});
@@ -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>
);
@@ -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&#39;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>
);
};
@@ -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)}
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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;
@@ -94,8 +115,29 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
});
} else {
const inviteeEmails = selectedMembers
.map((member) => member?.user.username as string)
.filter(Boolean);
.map((member) => {
if (!member) return null;
if (member.user.email) {
return member.user.email;
}
if (member.user.username) {
return member.user.username;
}
return null;
})
.filter(Boolean) as string[];
if (inviteeEmails.length !== selectedMembers.length) {
createNotification({
text: "Failed to add users to project. One or more users were invalid.",
type: "error"
});
return;
}
if (inviteeEmails.length || newInvitees.length) {
await addMembersToProject({
inviteeEmails: [...inviteeEmails, ...newInvitees],
@@ -126,12 +168,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 +183,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 +216,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 +252,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 +288,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 +298,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 +306,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="projectRoleSlugs"
@@ -263,6 +329,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: [
@@ -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) => {
@@ -17,6 +17,7 @@ import {
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretSyncActions,
ProjectPermissionSshHostActions,
TPermissionCondition,
@@ -67,6 +68,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(),
@@ -223,7 +233,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([]),
@@ -241,7 +256,9 @@ type TConditionalFields =
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.Identity
| ProjectPermissionSub.SshHosts;
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
export const isConditionalSubjects = (
subject: ProjectPermissionSub
@@ -251,7 +268,8 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SecretImports ||
subject === ProjectPermissionSub.SecretFolders ||
subject === ProjectPermissionSub.Identity ||
subject === ProjectPermissionSub.SshHosts;
subject === ProjectPermissionSub.SshHosts ||
subject === ProjectPermissionSub.SecretRotation;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = [];
@@ -317,6 +335,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);
@@ -940,10 +982,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>
);
};
@@ -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>
)}
/>
@@ -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>
);
})}
</>
);
};
@@ -0,0 +1 @@
export { SecretOverviewSecretRotationRow } from "./SecretOverviewSecretRotationRow";
@@ -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>
@@ -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>
@@ -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
@@ -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>
);
};
@@ -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"
@@ -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}>
@@ -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
});
@@ -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,12 +212,14 @@ const Page = () => {
imports,
folders,
dynamicSecrets,
secretRotations,
secrets,
totalImportCount = 0,
totalFolderCount = 0,
totalDynamicSecretCount = 0,
totalSecretCount = 0,
totalCount = 0
totalCount = 0,
totalSecretRotationCount = 0
} = data ?? {};
useResetPageHelper({
@@ -266,7 +279,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(
@@ -274,6 +288,7 @@ const Page = () => {
folders?.length ||
imports?.length ||
dynamicSecrets?.length ||
secretRotations?.length ||
noAccessSecretCount
);
@@ -363,6 +378,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 };
@@ -498,6 +515,9 @@ const Page = () => {
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecretRotations && Boolean(secretRotations?.length) && (
<SecretRotationListView secretRotations={secretRotations} />
)}
{canReadSecret && Boolean(secrets?.length) && (
<SecretListView
secrets={secrets}
@@ -524,6 +544,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"
}
@@ -17,10 +17,13 @@ import {
faKey,
faLock,
faMinusSquare,
faPaste,
faPlus,
faRotate,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import FileSaver from "file-saver";
import { twMerge } from "tailwind-merge";
@@ -28,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,
@@ -52,9 +56,21 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
import {
useCreateFolder,
useCreateSecretBatch,
useDeleteSecretBatch,
useMoveSecrets,
useUpdateSecretBatch
} from "@app/hooks/api";
import {
dashboardKeys,
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
@@ -65,11 +81,19 @@ import {
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter, RowType } from "../../SecretMainPage.types";
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
import { MoveSecretsModal } from "./MoveSecretsModal";
type TParsedEnv = Record<string, { value: string; comments: string[]; secretPath?: string }>;
type TParsedFolderEnv = Record<
string,
Record<string, { value: string; comments: string[]; secretPath?: string }>
>;
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
type Props = {
// switch the secrets type as it gets decrypted after api call
environment: string;
@@ -112,9 +136,12 @@ export const ActionBar = ({
"addDynamicSecret",
"addSecretImport",
"bulkDeleteSecrets",
"addSecretRotation",
"moveSecrets",
"misc",
"upgradePlan"
"upgradePlan",
"replicateFolder",
"confirmUpload"
] as const);
const isProtectedBranch = Boolean(protectedBranchPolicyName);
const { subscription } = useSubscription();
@@ -122,6 +149,13 @@ export const ActionBar = ({
const { mutateAsync: createFolder } = useCreateFolder();
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
const { mutateAsync: moveSecrets } = useMoveSecrets();
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
options: { onSuccess: undefined }
});
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
options: { onSuccess: undefined }
});
const queryClient = useQueryClient();
const selectedSecrets = useSelectedSecrets();
const { reset: resetSelectedSecret } = useSelectedSecretActions();
@@ -293,6 +327,285 @@ export const ActionBar = ({
}
};
// Replicate Folder Logic
const createSecretCount = Object.keys(
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
).length;
const updateSecretCount = Object.keys(
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
).length;
const isNonConflictingUpload = !updateSecretCount;
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
const handleParsedEnvMultiFolder = async (envByPath: TParsedFolderEnv) => {
if (Object.keys(envByPath).length === 0) {
createNotification({
type: "error",
text: "Failed to find secrets"
});
return;
}
try {
const allUpdateSecrets: TParsedEnv = {};
const allCreateSecrets: TParsedEnv = {};
await Promise.all(
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
// Normalize the path
let normalizedPath = folderPath;
// If the path is "/", use the current secretPath
if (normalizedPath === "/") {
normalizedPath = secretPath;
} else {
// Otherwise, concatenate with the current secretPath, avoiding double slashes
const baseSecretPath = secretPath.endsWith("/") ? secretPath.slice(0, -1) : secretPath;
// Remove leading slash from folder path if present to avoid double slashes
const cleanFolderPath = folderPath.startsWith("/")
? folderPath.substring(1)
: folderPath;
normalizedPath = `${baseSecretPath}/${cleanFolderPath}`;
}
const secretFolderKeys = Object.keys(secrets);
if (secretFolderKeys.length === 0) return;
// Check which secrets already exist in this path
const batchSize = 50;
const secretBatches = Array.from(
{ length: Math.ceil(secretFolderKeys.length / batchSize) },
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
);
const existingSecretLookup: Record<string, boolean> = {};
const processBatches = async () => {
await secretBatches.reduce(async (previous, batch) => {
await previous;
const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({
secretPath: normalizedPath,
environment,
projectId: workspaceId,
keys: batch
});
batchSecrets.forEach((secret) => {
existingSecretLookup[secret.secretKey] = true;
});
}, Promise.resolve());
};
await processBatches();
// Categorize each secret as update or create
secretFolderKeys.forEach((secretKey) => {
const secretData = secrets[secretKey];
// Store the path with the secret for later batch processing
const secretWithPath = {
...secretData,
secretPath: normalizedPath
};
if (existingSecretLookup[secretKey]) {
allUpdateSecrets[secretKey] = secretWithPath;
} else {
allCreateSecrets[secretKey] = secretWithPath;
}
});
})
);
handlePopUpOpen("confirmUpload", {
update: allUpdateSecrets,
create: allCreateSecrets
});
} catch (e) {
console.error(e);
createNotification({
text: "Failed to check for secret conflicts",
type: "error"
});
handlePopUpClose("confirmUpload");
}
};
const handleSaveFolderImport = async () => {
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
try {
// Group secrets by their path for batch operations
const groupedCreateSecrets: Record<
string,
Array<{
type: SecretType;
secretComment: string;
secretValue: string;
secretKey: string;
}>
> = {};
const groupedUpdateSecrets: Record<
string,
Array<{
type: SecretType;
secretComment: string;
secretValue: string;
secretKey: string;
}>
> = {};
// Collect all unique paths that need folders to be created
const allPaths = new Set<string>();
// Add paths from create secrets
Object.values(create || {}).forEach((secData) => {
if (secData.secretPath && secData.secretPath !== secretPath) {
allPaths.add(secData.secretPath);
}
});
// Create a map of folder paths to their folder name (last segment)
const folderPaths = Array.from(allPaths).map((path) => {
// Remove trailing slash if it exists
const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path;
// Split by '/' to get path segments
const segments = normalizedPath.split("/");
// Get the last segment as the folder name
const folderName = segments[segments.length - 1];
// Get the parent path (everything except the last segment)
const parentPath = segments.slice(0, -1).join("/");
return {
folderName,
fullPath: normalizedPath,
parentPath: parentPath || "/"
};
});
// Sort paths by depth (shortest first) to ensure parent folders are created before children
folderPaths.sort(
(a, b) => (a.fullPath.match(/\//g) || []).length - (b.fullPath.match(/\//g) || []).length
);
// Track created folders to avoid duplicates
const createdFolders = new Set<string>();
// Create all necessary folders in order using Promise.all and reduce
await folderPaths.reduce(async (previousPromise, { folderName, fullPath, parentPath }) => {
// Wait for the previous promise to complete
await previousPromise;
// Skip if we've already created this folder
if (createdFolders.has(fullPath)) return Promise.resolve();
try {
await createFolder({
name: folderName,
path: parentPath,
environment,
projectId: workspaceId
});
createdFolders.add(fullPath);
} catch (err) {
console.log(`Folder ${folderName} may already exist:`, err);
}
return Promise.resolve();
}, Promise.resolve());
if (Object.keys(create || {}).length > 0) {
Object.entries(create).forEach(([secretKey, secData]) => {
// Use the stored secretPath or fall back to the current secretPath
const path = secData.secretPath || secretPath;
if (!groupedCreateSecrets[path]) {
groupedCreateSecrets[path] = [];
}
groupedCreateSecrets[path].push({
type: SecretType.Shared,
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretKey
});
});
await Promise.all(
Object.entries(groupedCreateSecrets).map(([path, secrets]) =>
createSecretBatch({
secretPath: path,
workspaceId,
environment,
secrets
})
)
);
}
if (Object.keys(update || {}).length > 0) {
Object.entries(update).forEach(([secretKey, secData]) => {
// Use the stored secretPath or fall back to the current secretPath
const path = secData.secretPath || secretPath;
if (!groupedUpdateSecrets[path]) {
groupedUpdateSecrets[path] = [];
}
groupedUpdateSecrets[path].push({
type: SecretType.Shared,
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretKey
});
});
// Update secrets for each path in parallel
await Promise.all(
Object.entries(groupedUpdateSecrets).map(([path, secrets]) =>
updateSecretBatch({
secretPath: path,
workspaceId,
environment,
secrets
})
)
);
}
// Invalidate appropriate queries to refresh UI
queryClient.invalidateQueries({
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
});
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
});
queryClient.invalidateQueries({
queryKey: secretApprovalRequestKeys.count({ workspaceId })
});
// Close the modal and show notification
handlePopUpClose("confirmUpload");
createNotification({
type: "success",
text: isProtectedBranch
? "Uploaded changes have been sent for review"
: "Successfully uploaded secrets"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to upload secrets"
});
}
};
return (
<>
<div className="mt-4 flex items-center space-x-2">
@@ -364,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();
@@ -547,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, {
@@ -570,6 +927,29 @@ export const ActionBar = ({
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretFolders, {
environment,
secretPath
})}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faPaste} className="pr-2" />}
onClick={() => {
handlePopUpOpen("replicateFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Replicate Folder
</Button>
)}
</ProjectPermissionCan>
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -659,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)}
@@ -679,6 +1065,15 @@ export const ActionBar = ({
handlePopUpToggle={handlePopUpToggle}
onMoveApproved={handleSecretsMove}
/>
<ReplicateFolderFromBoard
isOpen={popUp.replicateFolder.isOpen}
onToggle={(isOpen) => handlePopUpToggle("replicateFolder", isOpen)}
onParsedEnv={handleParsedEnvMultiFolder}
environment={environment}
environments={currentWorkspace.environments}
workspaceId={workspaceId}
secretPath={secretPath}
/>
{subscription && (
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
@@ -690,6 +1085,58 @@ export const ActionBar = ({
}
/>
)}
<Modal
isOpen={popUp?.confirmUpload?.isOpen}
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
>
<ModalContent
title="Confirm Secret Upload"
footerContent={[
<Button
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
key="overwrite-btn"
onClick={handleSaveFolderImport}
>
{isNonConflictingUpload ? "Upload" : "Overwrite"}
</Button>,
<Button
key="keep-old-btn"
className="ml-4"
onClick={() => handlePopUpClose("confirmUpload")}
variant="outline_bg"
isDisabled={isSubmitting}
>
Cancel
</Button>
]}
>
{isNonConflictingUpload ? (
<div>
Are you sure you want to import {createSecretCount} secret
{createSecretCount > 1 ? "s" : ""} to this environment?
</div>
) : (
<div className="flex flex-col text-gray-300">
<div>Your project already contains the following {updateSecretCount} secrets:</div>
<div className="mt-2 text-sm text-gray-400">
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
?.map((key) => key)
.join(", ")}
</div>
<div className="mt-6">
Are you sure you want to overwrite these secrets
{createSecretCount > 0
? ` and import ${createSecretCount} new
one${createSecretCount > 1 ? "s" : ""}`
: ""}
?
</div>
</div>
)}
</ModalContent>
</Modal>
</>
);
};
@@ -0,0 +1,307 @@
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faClone } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalContent,
Switch
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { useDebounce } from "@app/hooks";
import { useGetAccessibleSecrets } from "@app/hooks/api/dashboard";
import { SecretV3Raw } from "@app/hooks/api/types";
import { SecretTreeView } from "./SecretTreeView";
const formSchema = z.object({
environment: z.object({ name: z.string(), slug: z.string() }),
secretPath: z
.string()
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
),
secrets: z
.object({
secretKey: z.string(),
secretValue: z.string().optional(),
secretPath: z.string()
})
.array()
.min(1, "Select one or more secrets to copy")
});
type TFormSchema = z.infer<typeof formSchema>;
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
onParsedEnv: (
env: Record<string, Record<string, { value: string; comments: string[]; secretPath?: string }>>
) => void;
environments?: { name: string; slug: string }[];
workspaceId: string;
environment: string;
secretPath: string;
};
type SecretFolder = {
items: Partial<SecretV3Raw>[];
subFolders: Record<string, SecretFolder>;
};
type SecretStructure = {
[rootPath: string]: SecretFolder;
};
export const ReplicateFolderFromBoard = ({
environments = [],
workspaceId,
isOpen,
onToggle,
onParsedEnv
}: Props) => {
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
const { handleSubmit, control, watch, reset, setValue } = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: { secretPath: "/", environment: environments?.[0], secrets: [] }
});
const envCopySecPath = watch("secretPath");
const selectedEnvSlug = watch("environment");
const selectedSecrets = watch("secrets");
const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath);
const { data: accessibleSecrets } = useGetAccessibleSecrets({
projectId: workspaceId,
secretPath: "/",
environment: selectedEnvSlug.slug,
recursive: true,
filterByAction: shouldIncludeValues
? ProjectPermissionSecretActions.ReadValue
: ProjectPermissionSecretActions.DescribeSecret,
options: { enabled: Boolean(workspaceId) && Boolean(selectedEnvSlug) && isOpen }
});
const restructureSecrets = useMemo(() => {
if (!accessibleSecrets) return {};
const result: SecretStructure = {};
result["/"] = {
items: [],
subFolders: {}
};
accessibleSecrets.forEach((secret) => {
const path = secret.secretPath || "/";
if (path === "/") {
result["/"]?.items.push(secret);
return;
}
const normalizedPath = path.startsWith("/") ? path.substring(1) : path;
const pathParts = normalizedPath.split("/");
let currentFolder = result["/"];
for (let i = 0; i < pathParts.length; i += 1) {
const part = pathParts[i];
// eslint-disable-next-line no-continue
if (!part) continue;
if (i === pathParts.length - 1) {
if (!currentFolder.subFolders[part]) {
currentFolder.subFolders[part] = {
items: [],
subFolders: {}
};
}
currentFolder.subFolders[part].items.push(secret);
} else {
if (!currentFolder.subFolders[part]) {
currentFolder.subFolders[part] = {
items: [],
subFolders: {}
};
}
currentFolder = currentFolder.subFolders[part];
}
}
});
return result;
}, [accessibleSecrets, selectedEnvSlug]);
const secretsFilteredByPath = useMemo(() => {
let normalizedPath = debouncedEnvCopySecretPath;
normalizedPath = debouncedEnvCopySecretPath.startsWith("/")
? debouncedEnvCopySecretPath
: `/${debouncedEnvCopySecretPath}`;
if (normalizedPath.length > 1 && normalizedPath.endsWith("/")) {
normalizedPath = debouncedEnvCopySecretPath.slice(0, -1);
}
if (normalizedPath === "/") {
return restructureSecrets["/"];
}
const segments = normalizedPath.split("/").filter((segment) => segment !== "");
let currentLevel = restructureSecrets["/"];
let result = null;
let currentPath = "";
if (!currentLevel) {
setValue("secretPath", "/");
return null;
}
for (let i = 0; i < segments.length; i += 1) {
const segment = segments[i];
currentPath += `/${segment}`;
if (currentLevel?.subFolders?.[segment]) {
currentLevel = currentLevel.subFolders[segment];
if (currentPath === normalizedPath) {
result = currentLevel;
break;
}
} else {
return null;
}
}
return result;
}, [restructureSecrets, debouncedEnvCopySecretPath]);
useEffect(() => {
setValue("secrets", []);
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
const handleFormSubmit = async (data: TFormSchema) => {
const secretsToBePulled: Record<
string,
Record<string, { value: string; comments: string[]; secretPath: string }>
> = {};
data.secrets.forEach(({ secretKey, secretValue, secretPath: secretPathToRecreate }) => {
const normalizedPath = secretPathToRecreate.startsWith(envCopySecPath)
? secretPathToRecreate.slice(envCopySecPath.length)
: secretPathToRecreate;
if (!secretsToBePulled[normalizedPath]) {
secretsToBePulled[normalizedPath] = {};
}
secretsToBePulled[normalizedPath][secretKey] = {
value: (shouldIncludeValues && secretValue) || "",
comments: [""],
secretPath: normalizedPath
};
});
onParsedEnv(secretsToBePulled);
onToggle(false);
reset();
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(state) => {
onToggle(state);
reset();
}}
>
<ModalContent
bodyClassName="overflow-visible"
className="max-w-2xl"
title="Replicate Folder Content From An Environment"
subTitle="Replicate folder content from other environments into this context"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="environment"
render={({ field: { value, onChange } }) => (
<FormControl label="Environment" isRequired className="w-1/3">
<FilterableSelect
value={value}
onChange={onChange}
options={environments}
placeholder="Select environment..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field }) => (
<FormControl label="Secret Path" className="flex-grow" isRequired>
<SecretPathInput
{...field}
placeholder="Provide a path, default is /"
environment={selectedEnvSlug?.slug}
/>
</FormControl>
)}
/>
</div>
<div className="border-t border-mineshaft-600 pt-4">
<Controller
control={control}
name="secrets"
render={({ field: { onChange } }) => (
<FormControl className="flex-grow" isRequired>
<SecretTreeView
data={secretsFilteredByPath}
basePath={debouncedEnvCopySecretPath}
onChange={onChange}
/>
</FormControl>
)}
/>
<div className="my-6 ml-2">
<Switch
id="populate-include-value"
isChecked={shouldIncludeValues}
onCheckedChange={(isChecked) => {
setValue("secrets", []);
setShouldIncludeValues(isChecked as boolean);
}}
>
Include secret values
</Switch>
</div>
<div className="flex items-center space-x-4">
<Button
leftIcon={<FontAwesomeIcon icon={faClone} />}
type="submit"
isDisabled={!selectedSecrets || selectedSecrets.length === 0}
>
Replicate Folder
</Button>
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
Cancel
</Button>
</div>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,321 @@
import React, { useEffect, useMemo, useState } from "react";
import {
faChevronDown,
faChevronRight,
faFolder,
faFolderOpen,
faFolderTree,
faKey
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { Checkbox } from "@app/components/v2";
interface SecretItem {
id?: string;
secretKey?: string;
secretValue?: string;
secretPath?: string;
[key: string]: any;
}
interface FolderStructure {
items: SecretItem[];
subFolders: {
[key: string]: FolderStructure;
};
}
interface TreeData {
[key: string]: FolderStructure | null;
}
interface FolderProps {
name: string;
structure: FolderStructure;
path: string;
selectedItems: SecretItem[];
onItemSelect: (item: SecretItem, isChecked: boolean) => void;
onFolderSelect: (folderPath: string, isChecked: boolean) => void;
isExpanded?: boolean;
level: number;
basePath?: string;
}
interface TreeViewProps {
data: FolderStructure | null;
basePath?: string;
className?: string;
onChange: (items: SecretItem[]) => void;
}
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
let items: SecretItem[] = [];
items = items.concat(folder.items);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Object.entries(folder.subFolders).forEach(([_, subFolder]) => {
items = items.concat(getAllItemsInFolder(subFolder));
});
return items;
};
const getDisplayName = (name: string): string => {
const parts = name.split("/");
return parts[parts.length - 1];
};
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
const CollapsibleContent = CollapsiblePrimitive.Content;
const Folder: React.FC<FolderProps> = ({
name,
structure,
path,
selectedItems,
onItemSelect,
onFolderSelect,
isExpanded = false,
level,
basePath
}) => {
const [open, setOpen] = useState(isExpanded);
const displayName = useMemo(() => getDisplayName(name), [name]);
const allItems = useMemo(() => getAllItemsInFolder(structure), [structure]);
const allItemIds = useMemo(() => allItems.map((item) => item.id), [allItems]);
const selectedItemIds = useMemo(() => selectedItems.map((item) => item.id), [selectedItems]);
const allSelected = useMemo(
() => allItemIds.length > 0 && allItemIds.every((id) => selectedItemIds.includes(id)),
[allItemIds, selectedItemIds]
);
const someSelected = useMemo(
() => allItemIds.some((id) => selectedItemIds.includes(id)) && !allSelected,
[allItemIds, selectedItemIds, allSelected]
);
const hasContents = structure.items.length > 0 || Object.keys(structure.subFolders).length > 0;
const handleFolderSelect = (checked: boolean) => {
onFolderSelect(path, checked);
};
return (
<div className={`folder-container ml-${level > 0 ? "4" : 0}`}>
<Collapsible open={open} onOpenChange={setOpen}>
<div className="group flex items-center rounded px-2 py-1">
<CollapsibleTrigger asChild>
<button
type="button"
className="mr-1 flex h-6 w-6 items-center justify-center rounded focus:outline-none"
disabled={!hasContents}
aria-label={open ? "Collapse folder" : "Expand folder"}
>
{hasContents && (
<FontAwesomeIcon icon={open ? faChevronDown : faChevronRight} className="h-3 w-3" />
)}
</button>
</CollapsibleTrigger>
<div className="mr-2">
<FontAwesomeIcon
// eslint-disable-next-line no-nested-ternary
icon={level > 0 ? (open ? faFolderOpen : faFolder) : faFolderTree}
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
/>
</div>
<Checkbox
id="folder-root"
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={allSelected || someSelected}
onCheckedChange={handleFolderSelect}
isIndeterminate={someSelected && !allSelected}
/>
<label
htmlFor={`folder-${path}`}
className={`ml-2 flex-1 cursor-pointer truncate ${basePath ? "italic text-mineshaft-300" : ""}`}
title={displayName}
>
{displayName || `${basePath}`}
</label>
{allItemIds.length > 0 && (
<span className="ml-2 text-xs text-mineshaft-400">
{allItemIds.length} {allItemIds.length === 1 ? "item" : "items"}
</span>
)}
</div>
<CollapsibleContent className="overflow-hidden transition-all duration-300 ease-in-out">
<div className="relative mt-1">
<div className="absolute bottom-0 left-5 top-0 w-px bg-mineshaft-600" />
{structure.items.map((item) => (
<div key={item.id} className="group ml-6 flex items-center rounded px-2 py-1">
<div className="ml-6 mr-2">
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
</div>
<Checkbox
id={`folder-${item.id}`}
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={selectedItemIds.includes(item.id)}
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
/>
<label
htmlFor={item.id}
className="ml-2 flex-1 cursor-pointer truncate"
title={item.secretKey}
>
{item.secretKey}
</label>
</div>
))}
{Object.entries(structure.subFolders).map(([subName, subStructure]) => (
<Folder
key={subName}
name={subName}
structure={subStructure}
path={path ? `${path}/${subName}` : subName}
selectedItems={selectedItems}
onItemSelect={onItemSelect}
onFolderSelect={onFolderSelect}
level={level + 1}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
export const SecretTreeView: React.FC<TreeViewProps> = ({
data,
basePath = "/",
className = "",
onChange
}) => {
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
const rootPath = "/";
const treeData: TreeData = data ? { [rootPath]: data as FolderStructure } : { [rootPath]: null };
const rootFolders = useMemo(() => {
return Object.entries(treeData);
}, [treeData]);
const isEmptyData = useMemo(() => {
return (
!data || (typeof data === "object" && Object.keys(data).length === 0) || !rootFolders.length
);
}, [data, rootFolders]);
const handleItemSelect = (item: SecretItem, isChecked: boolean) => {
if (isChecked) {
setSelectedItems((prev) => [...prev, item]);
} else {
setSelectedItems((prev) => prev.filter((i) => i.id !== item.id));
}
};
const handleFolderSelect = (folderPath: string, isChecked: boolean) => {
const getFolderFromPath = (tree: TreeData, path: string): FolderStructure | null => {
if (rootFolders.length === 1 && rootFolders[0][0] === path) {
return rootFolders[0][1];
}
let adjustedPath = path;
if (!path.startsWith(rootPath)) {
adjustedPath = rootPath === path ? rootPath : `${rootPath}/${path}`;
}
if (adjustedPath === "/") return tree["/"];
const parts = adjustedPath.split("/").filter((p) => p !== "");
let current: any;
current = tree["/"];
const targetExists = parts.every((part) => {
if (current?.subFolders?.[part]) {
current = current.subFolders[part];
return true;
}
return false;
});
if (!targetExists) {
return null;
}
return current;
};
const folder = getFolderFromPath(treeData, folderPath);
if (!folder) return;
const folderItems = getAllItemsInFolder(folder);
const folderItemIds = folderItems.map((item) => item.id);
if (isChecked) {
setSelectedItems((prev) => {
const prevIds = prev.map((item) => item.id);
const newItems = [...prev];
folderItems.forEach((item) => {
if (!prevIds.includes(item.id)) {
newItems.push(item);
}
});
return newItems;
});
} else {
setSelectedItems((prev) => prev.filter((item) => !folderItemIds.includes(item.id)));
}
};
useEffect(() => {
setSelectedItems([]);
}, [data]);
useEffect(() => {
onChange(selectedItems);
}, [selectedItems]);
return (
<div className="flex w-full items-start gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900">
<div className={`w-full rounded-lg shadow-sm ${className}`}>
<div className="h-[25vh] overflow-auto p-3">
{isEmptyData ? (
<div className="flex h-full w-full items-center justify-center text-center text-mineshaft-300">
<p>No secrets or folders available</p>
</div>
) : (
rootFolders.map(([folderName, folderStructure]) => (
<Folder
basePath={basePath}
key={folderName}
name={folderName}
structure={folderStructure || { items: [], subFolders: {} }}
path={folderName}
selectedItems={selectedItems}
onItemSelect={handleItemSelect}
onFolderSelect={handleFolderSelect}
isExpanded
level={0}
/>
))
)}
</div>
<div className="flex justify-end pb-2 pr-2 pt-2">
<h3 className="flex items-center text-mineshaft-400">
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
</h3>
</div>
</div>
</div>
);
};
@@ -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>
)}
/>
@@ -64,7 +64,8 @@ export const SecretDropzone = ({
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
options: { onSuccess: undefined }
});
// hide copy secrets from board due to import folders feature
const shouldRenderCopySecrets = false;
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
const handleDrag = (e: DragEvent) => {
@@ -308,16 +309,18 @@ export const SecretDropzone = ({
secretPath={secretPath}
isSmaller={isSmaller}
/>
<CopySecretsFromBoard
isOpen={popUp.importSecEnv.isOpen}
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
onParsedEnv={handleParsedEnv}
environment={environment}
environments={environments}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isSmaller}
/>
{shouldRenderCopySecrets && (
<CopySecretsFromBoard
isOpen={popUp.importSecEnv.isOpen}
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
onParsedEnv={handleParsedEnv}
environment={environment}
environments={environments}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isSmaller}
/>
)}
{!isSmaller && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
@@ -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>
)
)}
@@ -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}
@@ -79,7 +79,8 @@ export const SecretListView = ({
skipMultilineEncoding,
newKey,
secretId,
secretMetadata
secretMetadata,
isRotatedSecret
}: Partial<{
value: string;
comment: string;
@@ -90,6 +91,7 @@ export const SecretListView = ({
newKey: string;
secretId: string;
secretMetadata?: { key: string; value: string }[];
isRotatedSecret?: boolean;
}> = {}
) => {
if (operation === "delete") {
@@ -110,14 +112,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;
@@ -213,7 +217,8 @@ export const SecretListView = ({
secretId: orgSecret.id,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding,
secretMetadata
secretMetadata,
isRotatedSecret: orgSecret.isRotatedSecret
});
if (cb) cb();
}
@@ -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>
))}
@@ -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>
</>
);
};
@@ -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)}
/>
</>
);
};
@@ -0,0 +1 @@
export { SecretRotationListView } from "./SecretRotationListView";
@@ -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>
@@ -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
@@ -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>
</>
);
};
@@ -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>
)}
</>
);
@@ -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>
</>
);
};
@@ -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>;
};
@@ -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>;
};
@@ -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>;
};
@@ -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}
</>
);
@@ -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}
</>
);
@@ -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>
@@ -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>
@@ -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>
)}
</>
);
@@ -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>
)}
</>
);
@@ -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>
@@ -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>