mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 22:17:52 +00:00
Merge branch 'heads/main' into daniel/scim-deprovisioning-ui
This commit is contained in:
+4
-2
@@ -24,7 +24,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Displaying audit logs from the last {auditLogsRetentionDays} days
|
||||
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
|
||||
</p>
|
||||
</div>
|
||||
<LogsSection
|
||||
@@ -32,7 +32,9 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
||||
showFilters={false}
|
||||
presets={{
|
||||
eventMetadata: { integrationId: integration.id },
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
||||
startDate: new Date(
|
||||
new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60))
|
||||
),
|
||||
eventType: INTEGRATION_EVENTS
|
||||
}}
|
||||
/>
|
||||
|
||||
+54
-13
@@ -1,5 +1,8 @@
|
||||
import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render";
|
||||
import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
import {
|
||||
useRenderConnectionListEnvironmentGroups,
|
||||
useRenderConnectionListServices
|
||||
} from "@app/hooks/api/appConnections/render";
|
||||
import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
|
||||
import { getSecretSyncDestinationColValues } from "../helpers";
|
||||
import { SecretSyncTableCell } from "../SecretSyncTableCell";
|
||||
@@ -9,21 +12,59 @@ type Props = {
|
||||
};
|
||||
|
||||
export const RenderSyncDestinationCol = ({ secretSync }: Props) => {
|
||||
const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service;
|
||||
|
||||
const { data: services = [], isPending } = useRenderConnectionListServices(
|
||||
secretSync.connectionId
|
||||
secretSync.connectionId,
|
||||
{
|
||||
enabled: isServiceScope
|
||||
}
|
||||
);
|
||||
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
|
||||
...secretSync,
|
||||
destinationConfig: {
|
||||
...secretSync.destinationConfig,
|
||||
serviceName: services.find((s) => s.id === secretSync.destinationConfig.serviceId)?.name
|
||||
const { data: groups = [], isPending: isGroupsPending } =
|
||||
useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope });
|
||||
|
||||
switch (secretSync.destinationConfig.scope) {
|
||||
case RenderSyncScope.Service: {
|
||||
const id = secretSync.destinationConfig.serviceId;
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
|
||||
...secretSync,
|
||||
destinationConfig: {
|
||||
...secretSync.destinationConfig,
|
||||
serviceName: services.find((s) => s.id === id)?.name
|
||||
}
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<SecretSyncTableCell primaryText="Loading service info..." secondaryText="Service" />
|
||||
);
|
||||
}
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
}
|
||||
});
|
||||
case RenderSyncScope.EnvironmentGroup: {
|
||||
const id = secretSync.destinationConfig.environmentGroupId;
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
|
||||
...secretSync,
|
||||
destinationConfig: {
|
||||
...secretSync.destinationConfig,
|
||||
environmentGroupName: groups.find((s) => s.id === id)?.name
|
||||
}
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <SecretSyncTableCell primaryText="Loading service info..." secondaryText="Service" />;
|
||||
if (isGroupsPending) {
|
||||
return (
|
||||
<SecretSyncTableCell
|
||||
primaryText="Loading environment group info..."
|
||||
secondaryText="Environment Group"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown render sync destination scope");
|
||||
}
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
};
|
||||
|
||||
+10
-2
@@ -8,6 +8,7 @@ import {
|
||||
} from "@app/hooks/api/secretSyncs/types/github-sync";
|
||||
import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync";
|
||||
import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync";
|
||||
import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
|
||||
// This functional ensures parity across what is displayed in the destination column
|
||||
// and the values used when search filtering
|
||||
@@ -125,8 +126,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
|
||||
secondaryText = destinationConfig.app;
|
||||
break;
|
||||
case SecretSync.Render:
|
||||
primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId;
|
||||
secondaryText = "Service";
|
||||
if (destinationConfig.scope === RenderSyncScope.Service) {
|
||||
primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId;
|
||||
secondaryText = "Service";
|
||||
} else {
|
||||
primaryText =
|
||||
destinationConfig.environmentGroupName ?? destinationConfig.environmentGroupId;
|
||||
secondaryText = "Environment Group";
|
||||
}
|
||||
|
||||
break;
|
||||
case SecretSync.Flyio:
|
||||
primaryText = destinationConfig.appId;
|
||||
|
||||
+13
@@ -10,6 +10,7 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -51,6 +52,7 @@ export const SelectionPanel = ({
|
||||
usedBySecretSyncs = []
|
||||
}: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"bulkDeleteEntries",
|
||||
@@ -101,6 +103,16 @@ export const SelectionPanel = ({
|
||||
return "Do you want to delete the selected folders across environments?";
|
||||
};
|
||||
|
||||
const getDeleteModalSubTitle = () => {
|
||||
if (selectedFolderCount > 0) {
|
||||
if (subscription?.pitRecovery) {
|
||||
return "All selected folders and their contents will be removed. You can reverse this action by rolling back to a previous commit.";
|
||||
}
|
||||
return "All selected folders and their contents will be removed. Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature.";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
let processedEntries = 0;
|
||||
|
||||
@@ -279,6 +291,7 @@ export const SelectionPanel = ({
|
||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||
deleteKey="delete"
|
||||
title={getDeleteModalTitle()}
|
||||
subTitle={getDeleteModalSubTitle()}
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||
onDeleteApproved={handleBulkDelete}
|
||||
formContent={
|
||||
|
||||
+10
@@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({
|
||||
setSelectedRequest(null);
|
||||
refetchRequests();
|
||||
}}
|
||||
onUpdate={(request) => {
|
||||
// scott: this isn't ideal but our current use of state makes this complicated...
|
||||
// we shouldn't be using state like this...
|
||||
handleSelectRequest({
|
||||
...selectedRequest,
|
||||
isTemporary: request.isTemporary,
|
||||
temporaryRange: request.temporaryRange,
|
||||
reviewers: []
|
||||
});
|
||||
}}
|
||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||
/>
|
||||
)}
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation";
|
||||
import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries";
|
||||
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||
|
||||
type ContentProps = {
|
||||
accessRequest: TAccessApprovalRequest;
|
||||
onComplete: (request: TAccessApprovalRequest) => void;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
const EditSchema = z.object({
|
||||
temporaryRange: z
|
||||
.string()
|
||||
.nonempty("Required")
|
||||
.transform((val, ctx) => {
|
||||
const parsedMs = ms(val);
|
||||
|
||||
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return val;
|
||||
}),
|
||||
editNote: z.string().nonempty("Required")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof EditSchema>;
|
||||
|
||||
const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => {
|
||||
const update = useUpdateAccessRequest();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm({
|
||||
resolver: zodResolver(EditSchema),
|
||||
defaultValues: {
|
||||
temporaryRange: accessRequest.temporaryRange ?? "1h",
|
||||
editNote: ""
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async (form: FormData) => {
|
||||
try {
|
||||
const request = await update.mutateAsync({
|
||||
requestId: accessRequest.id,
|
||||
projectSlug,
|
||||
...form
|
||||
});
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug)
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Access request updated successfully."
|
||||
});
|
||||
onComplete(request);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update access request"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 flex w-full items-start rounded-md border border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
|
||||
<FontAwesomeIcon icon={faWarning} className="mr-2.5 mt-1 text-base text-yellow" />
|
||||
Updating this access request will restart the review process and require all approvers to
|
||||
re-approve it.
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue="1h"
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Access Duration" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="editNote"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Reason for Editing"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea
|
||||
className="!resize-none"
|
||||
rows={4}
|
||||
{...field}
|
||||
placeholder="Provide a reason for updating this request..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 flex gap-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline_bg"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Update Request
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
accessRequest?: TAccessApprovalRequest;
|
||||
onComplete: (request: TAccessApprovalRequest) => void;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export const EditAccessRequestModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
accessRequest,
|
||||
onComplete,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
if (!accessRequest) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Edit Access Request"
|
||||
subTitle="Modify this access request for re-approval."
|
||||
>
|
||||
<Content
|
||||
projectSlug={projectSlug}
|
||||
accessRequest={accessRequest}
|
||||
onComplete={(request) => {
|
||||
onComplete(request);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+39
-12
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
faBan,
|
||||
faCheck,
|
||||
faEdit,
|
||||
faHourglass,
|
||||
faQuestionCircle,
|
||||
faTriangleExclamation,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
|
||||
import {
|
||||
Approver,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
|
||||
|
||||
const getReviewedStatusSymbol = (status?: ApprovalStatus, isOrgMembershipActive?: boolean) => {
|
||||
if (status === ApprovalStatus.APPROVED)
|
||||
@@ -75,7 +79,8 @@ export const ReviewAccessRequestModal = ({
|
||||
selectedEnvSlug,
|
||||
canBypass,
|
||||
policies = [],
|
||||
members = []
|
||||
members = [],
|
||||
onUpdate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
@@ -91,6 +96,7 @@ export const ReviewAccessRequestModal = ({
|
||||
canBypass: boolean;
|
||||
policies: TAccessApprovalPolicy[];
|
||||
members: TWorkspaceUser[];
|
||||
onUpdate: (request: TAccessApprovalRequest) => void;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||
const [bypassApproval, setBypassApproval] = useState(false);
|
||||
@@ -100,6 +106,8 @@ export const ReviewAccessRequestModal = ({
|
||||
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||
const { user } = useUser();
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editRequest"] as const);
|
||||
|
||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||
|
||||
const accessDetails = {
|
||||
@@ -135,16 +143,9 @@ export const ReviewAccessRequestModal = ({
|
||||
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||
return "Permanent";
|
||||
|
||||
// convert the range to human readable format
|
||||
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
||||
|
||||
return (
|
||||
<Badge>
|
||||
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||
long: true
|
||||
})} after approval`}
|
||||
</Badge>
|
||||
);
|
||||
return `Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||
long: true
|
||||
})} after approval`;
|
||||
};
|
||||
|
||||
const reviewAccessRequest = useReviewAccessRequest();
|
||||
@@ -303,7 +304,33 @@ export const ReviewAccessRequestModal = ({
|
||||
<GenericFieldLabel truncate label="Secret Path">
|
||||
{accessDetails.secretPath}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Access Duration">
|
||||
<div className="flex h-min gap-1">
|
||||
{getAccessLabel()}
|
||||
{request.isApprover && request.status === ApprovalStatus.PENDING && (
|
||||
<>
|
||||
<EditAccessRequestModal
|
||||
isOpen={popUp.editRequest.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("editRequest", open)}
|
||||
accessRequest={request}
|
||||
onComplete={onUpdate}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
<Tooltip content="Edit Access Duration">
|
||||
<IconButton
|
||||
onClick={() => handlePopUpOpen("editRequest")}
|
||||
variant="plain"
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
ariaLabel="Edit access duration"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
|
||||
{request.note && (
|
||||
<GenericFieldLabel className="col-span-full" label="Note">
|
||||
|
||||
+34
-22
@@ -14,6 +14,7 @@ import {
|
||||
faFingerprint,
|
||||
faFolder,
|
||||
faFolderPlus,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faLock,
|
||||
faPaste,
|
||||
@@ -32,6 +33,7 @@ import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
@@ -97,7 +99,7 @@ import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[]; secretPath?: string }>;
|
||||
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
|
||||
type TParsedFolderEnv = Record<
|
||||
string,
|
||||
Record<string, { value: string; comments: string[]; secretPath?: string }>
|
||||
@@ -405,8 +407,8 @@ export const ActionBar = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const allUpdateSecrets: TParsedEnv = {};
|
||||
const allCreateSecrets: TParsedEnv = {};
|
||||
const allUpdateSecrets: TParsedEnv = [];
|
||||
const allCreateSecrets: TParsedEnv = [];
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
|
||||
@@ -437,7 +439,7 @@ export const ActionBar = ({
|
||||
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
|
||||
);
|
||||
|
||||
const existingSecretLookup: Record<string, boolean> = {};
|
||||
const existingSecretLookup = new Set<string>();
|
||||
|
||||
const processBatches = async () => {
|
||||
await secretBatches.reduce(async (previous, batch) => {
|
||||
@@ -451,7 +453,7 @@ export const ActionBar = ({
|
||||
});
|
||||
|
||||
batchSecrets.forEach((secret) => {
|
||||
existingSecretLookup[secret.secretKey] = true;
|
||||
existingSecretLookup.add(`${normalizedPath}-${secret.secretKey}`);
|
||||
});
|
||||
}, Promise.resolve());
|
||||
};
|
||||
@@ -465,18 +467,18 @@ export const ActionBar = ({
|
||||
// Store the path with the secret for later batch processing
|
||||
const secretWithPath = {
|
||||
...secretData,
|
||||
secretPath: normalizedPath
|
||||
secretPath: normalizedPath,
|
||||
secretKey
|
||||
};
|
||||
|
||||
if (existingSecretLookup[secretKey]) {
|
||||
allUpdateSecrets[secretKey] = secretWithPath;
|
||||
if (existingSecretLookup.has(`${normalizedPath}-${secretKey}`)) {
|
||||
allUpdateSecrets.push(secretWithPath);
|
||||
} else {
|
||||
allCreateSecrets[secretKey] = secretWithPath;
|
||||
allCreateSecrets.push(secretWithPath);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
handlePopUpOpen("confirmUpload", {
|
||||
update: allUpdateSecrets,
|
||||
create: allCreateSecrets
|
||||
@@ -519,7 +521,7 @@ export const ActionBar = ({
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
// Add paths from create secrets
|
||||
Object.values(create || {}).forEach((secData) => {
|
||||
create.forEach((secData) => {
|
||||
if (secData.secretPath && secData.secretPath !== secretPath) {
|
||||
allPaths.add(secData.secretPath);
|
||||
}
|
||||
@@ -575,8 +577,8 @@ export const ActionBar = ({
|
||||
return Promise.resolve();
|
||||
}, Promise.resolve());
|
||||
|
||||
if (Object.keys(create || {}).length > 0) {
|
||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
||||
if (create.length > 0) {
|
||||
create.forEach((secData) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
@@ -588,7 +590,7 @@ export const ActionBar = ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
secretKey: secData.secretKey
|
||||
});
|
||||
});
|
||||
|
||||
@@ -604,8 +606,8 @@ export const ActionBar = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(update || {}).length > 0) {
|
||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
||||
if (update.length > 0) {
|
||||
update.forEach((secData) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
@@ -617,7 +619,7 @@ export const ActionBar = ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
secretKey: secData.secretKey
|
||||
});
|
||||
});
|
||||
|
||||
@@ -665,6 +667,8 @@ export const ActionBar = ({
|
||||
const isTableFiltered =
|
||||
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
||||
|
||||
const filteredTags = Object.values(filter?.tags ?? {}).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
@@ -697,7 +701,7 @@ export const ActionBar = ({
|
||||
Filters
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuContent align="end" sideOffset={2} className="p-0">
|
||||
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -776,11 +780,19 @@ export const ActionBar = ({
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Tags
|
||||
<div className="flex w-full justify-between">
|
||||
<span>Tags</span>
|
||||
{Boolean(filteredTags) && <Badge>{filteredTags} Applied</Badge>}
|
||||
</div>
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Apply Tags to Filter Secrets
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>Filter by Secret Tags</span>
|
||||
<Tooltip content="Matches secrets with one or more of the applied tags">
|
||||
<FontAwesomeIcon icon={faInfoCircle} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
{tags.map(({ id, slug, color }) => (
|
||||
<DropdownMenuItem
|
||||
@@ -1229,8 +1241,8 @@ export const ActionBar = ({
|
||||
<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)
|
||||
{(popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update
|
||||
?.map((sec) => sec.secretKey)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
|
||||
+948
@@ -0,0 +1,948 @@
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
SecretInput,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
// Component for managing scopes and collections within a bucket
|
||||
const BucketScopesConfiguration = ({
|
||||
control,
|
||||
bucketIndex,
|
||||
bucketsValue,
|
||||
setValue,
|
||||
addScope,
|
||||
removeScope,
|
||||
addCollection,
|
||||
removeCollection
|
||||
}: {
|
||||
control: any;
|
||||
bucketIndex: number;
|
||||
bucketsValue: any;
|
||||
setValue: any;
|
||||
addScope: (bucketIndex: number) => void;
|
||||
removeScope: (bucketIndex: number, scopeIndex: number) => void;
|
||||
addCollection: (bucketIndex: number, scopeIndex: number) => void;
|
||||
removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void;
|
||||
}) => {
|
||||
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
|
||||
const scopeFields = bucket?.scopes || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-mineshaft-300">Scopes</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => addScope(bucketIndex)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Scope
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopeFields.map((_scope: any, scopeIndex: number) => (
|
||||
<div
|
||||
key={`scope-${scopeIndex + 1}`}
|
||||
className="space-y-3 rounded border border-mineshaft-600 bg-mineshaft-700 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="plain"
|
||||
ariaLabel="Remove scope"
|
||||
size="sm"
|
||||
onClick={() => removeScope(bucketIndex, scopeIndex)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`provider.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pl-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-mineshaft-300">Collections</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => addCollection(bucketIndex, scopeIndex)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Collection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopeFields[scopeIndex]?.collections?.map(
|
||||
(collection: string, collectionIndex: number) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FormControl className="flex-1">
|
||||
<Input
|
||||
value={collection || ""}
|
||||
onChange={(e) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||
currentBuckets[bucketIndex].scopes[scopeIndex].collections[
|
||||
collectionIndex
|
||||
] = e.target.value;
|
||||
setValue("provider.buckets", currentBuckets);
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., airport, airline"
|
||||
className="text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="plain"
|
||||
ariaLabel="Remove collection"
|
||||
className="mb-4"
|
||||
size="sm"
|
||||
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{(!scopeFields[scopeIndex]?.collections ||
|
||||
scopeFields[scopeIndex].collections.length === 0) && (
|
||||
<div className="text-xs italic text-mineshaft-400">
|
||||
No collections specified (access to all collections in scope)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scopeFields.length === 0 && (
|
||||
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
|
||||
<p className="mb-2 text-xs text-mineshaft-400">
|
||||
No scopes configured (access to all scopes in bucket)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => addScope(bucketIndex)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Scope
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const couchbaseRoles = [
|
||||
{ value: "read", label: "Read", description: "Read-only access to bucket data" },
|
||||
{
|
||||
value: "write",
|
||||
label: "Write",
|
||||
description: "Full write access to bucket data"
|
||||
}
|
||||
];
|
||||
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(8, "Password must be at least 8 characters").max(128),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(1, "At least 1 lowercase character required"),
|
||||
uppercase: z.number().min(1, "At least 1 uppercase character required"),
|
||||
digits: z.number().min(1, "At least 1 digit required"),
|
||||
symbols: z.number().min(1, "At least 1 special character required")
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 128;
|
||||
}, "Sum of required characters cannot exceed 128"),
|
||||
allowedSymbols: z
|
||||
.string()
|
||||
.refine((symbols) => {
|
||||
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
|
||||
return !forbiddenChars.some((char) => symbols?.includes(char));
|
||||
}, "Cannot contain: < > ; . * & | £")
|
||||
.optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
|
||||
const bucketSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bucket name is required"),
|
||||
scopes: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, "Scope name is required"),
|
||||
collections: z.array(z.string().trim().min(1)).optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
url: z.string().url().trim().min(1),
|
||||
orgId: z.string().trim().min(1),
|
||||
projectId: z.string().trim().min(1),
|
||||
clusterId: z.string().trim().min(1),
|
||||
roles: z.array(z.string()).min(1, "At least one role must be selected"),
|
||||
buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]),
|
||||
useAdvancedBuckets: z.boolean().default(false),
|
||||
passwordRequirements: passwordRequirementsSchema.optional(),
|
||||
auth: z.object({
|
||||
apiKey: z.string().trim().min(1)
|
||||
})
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: slugSchema(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
usernameTemplate: z.string().nullable().optional()
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environments: WorkspaceEnv[];
|
||||
isSingleEnvironmentMode?: boolean;
|
||||
};
|
||||
|
||||
export const CouchbaseInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
environments,
|
||||
secretPath,
|
||||
projectSlug,
|
||||
isSingleEnvironmentMode
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setValue,
|
||||
getValues,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
provider: {
|
||||
url: "https://cloudapi.cloud.couchbase.com",
|
||||
roles: ["read"],
|
||||
buckets: "*",
|
||||
useAdvancedBuckets: false,
|
||||
passwordRequirements: {
|
||||
length: 12,
|
||||
required: {
|
||||
lowercase: 1,
|
||||
uppercase: 1,
|
||||
digits: 1,
|
||||
symbols: 1
|
||||
},
|
||||
allowedSymbols: "!@#$%^()_+-=[]{}:,?/~`"
|
||||
},
|
||||
auth: {
|
||||
apiKey: ""
|
||||
}
|
||||
},
|
||||
environment: isSingleEnvironmentMode ? environments[0] : undefined,
|
||||
usernameTemplate: "{{randomUsername}}"
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
|
||||
const isAdvancedMode = watch("provider.useAdvancedBuckets");
|
||||
const bucketsValue = watch("provider.buckets");
|
||||
|
||||
const addBucket = () => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||
setValue("provider.buckets", [...currentBuckets, { name: "", scopes: [] }]);
|
||||
};
|
||||
|
||||
const removeBucket = (index: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||
const newBuckets = currentBuckets.filter((_, i) => i !== index);
|
||||
setValue("provider.buckets", newBuckets);
|
||||
};
|
||||
|
||||
const addScope = (bucketIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]) {
|
||||
const currentScopes = currentBuckets[bucketIndex].scopes || [];
|
||||
currentBuckets[bucketIndex] = {
|
||||
...currentBuckets[bucketIndex],
|
||||
scopes: [...currentScopes, { name: "", collections: [] }]
|
||||
};
|
||||
setValue("provider.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const removeScope = (bucketIndex: number, scopeIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes) {
|
||||
currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter(
|
||||
(_, i) => i !== scopeIndex
|
||||
);
|
||||
setValue("provider.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const addCollection = (bucketIndex: number, scopeIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]) {
|
||||
const currentCollections = currentBuckets[bucketIndex].scopes[scopeIndex].collections || [];
|
||||
currentBuckets[bucketIndex].scopes[scopeIndex].collections = [...currentCollections, ""];
|
||||
setValue("provider.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||
currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[
|
||||
bucketIndex
|
||||
].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex);
|
||||
setValue("provider.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDynamicSecret = async ({
|
||||
name,
|
||||
maxTTL,
|
||||
provider,
|
||||
defaultTTL,
|
||||
environment,
|
||||
usernameTemplate
|
||||
}: TForm) => {
|
||||
if (createDynamicSecret.isPending) return;
|
||||
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
|
||||
|
||||
const transformedProvider = {
|
||||
...provider,
|
||||
buckets: provider.useAdvancedBuckets ? provider.buckets : (provider.buckets as string)
|
||||
};
|
||||
|
||||
const { useAdvancedBuckets, ...finalProvider } = transformedProvider;
|
||||
|
||||
try {
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: { type: DynamicSecretProviders.Couchbase, inputs: finalProvider },
|
||||
maxTTL,
|
||||
name,
|
||||
path: secretPath,
|
||||
defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: environment.slug,
|
||||
usernameTemplate:
|
||||
!usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate
|
||||
});
|
||||
onCompleted();
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.url"
|
||||
defaultValue="https://cloudapi.cloud.couchbase.com"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="URL"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder="https://cloudapi.cloud.couchbase.com" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.orgId"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Organization ID"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.projectId"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Project ID"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.clusterId"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster ID"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.roles"
|
||||
defaultValue={["read"]}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Roles"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Select one or more roles to assign to the user"
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
value={couchbaseRoles.filter((role) => value?.includes(role.value))}
|
||||
onChange={(selectedRoles) => {
|
||||
if (Array.isArray(selectedRoles)) {
|
||||
onChange(selectedRoles.map((role: any) => role.value));
|
||||
} else {
|
||||
onChange([]);
|
||||
}
|
||||
}}
|
||||
options={couchbaseRoles}
|
||||
placeholder="Select roles..."
|
||||
getOptionLabel={(option) => option.label}
|
||||
getOptionValue={(option) => option.value}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.useAdvancedBuckets"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl
|
||||
label="Advanced Bucket Configuration"
|
||||
helperText="Enable to configure specific buckets, scopes and collections. When disabled, '*' grants access to all buckets, scopes, and collections."
|
||||
>
|
||||
<Switch
|
||||
id="advanced-buckets-switch"
|
||||
isChecked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
const bucketsController = getValues("provider.buckets");
|
||||
if (checked && typeof bucketsController === "string") {
|
||||
setValue("provider.buckets", []);
|
||||
} else if (!checked && Array.isArray(bucketsController)) {
|
||||
setValue("provider.buckets", "*");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!watch("provider.useAdvancedBuckets") && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.buckets"
|
||||
defaultValue="*"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bucket Access"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets, scopes, and collections"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={typeof field.value === "string" ? field.value : "*"}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdvancedMode && Array.isArray(bucketsValue) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-mineshaft-200">
|
||||
Advanced Bucket Configuration
|
||||
</div>
|
||||
<div className="text-sm text-mineshaft-400">
|
||||
Configure specific buckets with their scopes and collections. Leave scopes
|
||||
empty for access to all scopes in a bucket.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={addBucket}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Array.isArray(bucketsValue) &&
|
||||
(bucketsValue as any[]).map((_, bucketIndex) => (
|
||||
<div
|
||||
key={`bucket-${bucketIndex + 1}`}
|
||||
className="space-y-4 rounded border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-mineshaft-200">
|
||||
Bucket {bucketIndex + 1}
|
||||
</h4>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="plain"
|
||||
ariaLabel="Remove bucket"
|
||||
onClick={() => removeBucket(bucketIndex)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`provider.buckets.${bucketIndex}.name`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bucket Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="e.g., travel-sample" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<BucketScopesConfiguration
|
||||
control={control}
|
||||
bucketIndex={bucketIndex}
|
||||
bucketsValue={bucketsValue}
|
||||
setValue={setValue}
|
||||
addScope={addScope}
|
||||
removeScope={removeScope}
|
||||
addCollection={addCollection}
|
||||
removeCollection={removeCollection}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && (
|
||||
<div className="rounded border border-dashed border-mineshaft-600 p-8 text-center">
|
||||
<p className="mb-2 text-sm text-mineshaft-400">No buckets configured</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={addBucket}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add First Bucket
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
name="provider.auth.apiKey"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className="w-full"
|
||||
label="API Key"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
valueAlwaysHidden
|
||||
rows={1}
|
||||
wrap="hard"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="usernameTemplate"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username Template"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || undefined}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
placeholder="{{randomUsername}}"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="password-config">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Password Configuration (optional)</span>
|
||||
<Tooltip content="Couchbase password requirements: minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character. Cannot contain: < > ; . * & | £">
|
||||
<div className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-mineshaft-600 text-xs text-mineshaft-300">
|
||||
?
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Set constraints on the generated Couchbase user password (8-128 characters)
|
||||
<br />
|
||||
<span className="text-xs text-mineshaft-400">
|
||||
Forbidden characters: < > ; . * & | £
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.length"
|
||||
defaultValue={12}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password Length"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={8}
|
||||
max={128}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(() => {
|
||||
const total = Object.values(
|
||||
watch("provider.passwordRequirements.required") || {}
|
||||
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const length = watch("provider.passwordRequirements.length") || 0;
|
||||
const isError = total > length;
|
||||
return (
|
||||
<span className={isError ? "text-red-500" : ""}>
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.lowercase"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Lowercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min lowercase letters (required: ≥1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.uppercase"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Uppercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min uppercase letters (required: ≥1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.digits"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Digit Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min digits (required: ≥1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.symbols"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Symbol Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min special characters (required: ≥1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Allowed Symbols</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.allowedSymbols"
|
||||
defaultValue="!@#$%^()_+-=[]{}:,?/~`"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Allowed Symbols"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Cannot contain: < > ; . * & | £"
|
||||
>
|
||||
<Input {...field} placeholder="!@#$%^()_+-=[]{}:,?/~`" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{!isSingleEnvironmentMode && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
options={environments}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select the environment to create secret in..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
menuPlacement="top"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+26
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { DiRedis } from "react-icons/di";
|
||||
import {
|
||||
SiApachecassandra,
|
||||
SiCouchbase,
|
||||
SiElasticsearch,
|
||||
SiFiles,
|
||||
SiKubernetes,
|
||||
@@ -29,6 +30,7 @@ import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
|
||||
import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||
import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { CouchbaseInputForm } from "./CouchbaseInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
import { GcpIamInputForm } from "./GcpIamInputForm";
|
||||
import { GithubInputForm } from "./GithubInputForm";
|
||||
@@ -154,6 +156,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
|
||||
provider: DynamicSecretProviders.Github,
|
||||
title: "GitHub"
|
||||
},
|
||||
{
|
||||
icon: <SiCouchbase size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.Couchbase,
|
||||
title: "Couchbase"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -608,6 +615,25 @@ export const CreateDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.Couchbase && (
|
||||
<motion.div
|
||||
key="dynamic-couchbase-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<CouchbaseInputForm
|
||||
onCompleted={handleFormReset}
|
||||
onCancel={handleFormReset}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
environments={environments}
|
||||
isSingleEnvironmentMode={isSingleEnvironmentMode}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
+55
-40
@@ -8,6 +8,7 @@ import {
|
||||
faSave
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
@@ -302,47 +303,61 @@ export const CommitForm: React.FC<CommitFormProps> = ({
|
||||
<>
|
||||
{/* Floating Panel */}
|
||||
{!isModalOpen && (
|
||||
<div className="fixed bottom-4 left-1/2 z-40 w-full max-w-3xl -translate-x-1/2 self-center rounded-lg border border-yellow/30 bg-mineshaft-800 shadow-2xl lg:left-auto lg:translate-x-0">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{/* Left Content */}
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||
<span className="font-medium text-mineshaft-100">Pending Changes</span>
|
||||
<Badge variant="primary" className="text-xs">
|
||||
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
<div className="fixed bottom-4 left-1/2 z-40 w-full max-w-3xl -translate-x-1/2 self-center lg:left-auto lg:translate-x-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="commit-panel"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateY: 30 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
exit={{ opacity: 0, translateY: -30 }}
|
||||
>
|
||||
<div className="rounded-lg border border-yellow/30 bg-mineshaft-800 shadow-2xl">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{/* Left Content */}
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||
<span className="font-medium text-mineshaft-100">Pending Changes</span>
|
||||
<Badge variant="primary" className="text-xs">
|
||||
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm leading-5 text-mineshaft-400">
|
||||
Review pending changes and commit them to apply the updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Buttons */}
|
||||
<div className="ml-6 mt-0.5 flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
clearAllPendingChanges({ workspaceId, environment, secretPath })
|
||||
}
|
||||
isDisabled={totalChangesCount === 0}
|
||||
variant="outline_bg"
|
||||
className="px-4 hover:border-red/40 hover:bg-red/[0.1]"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
isDisabled={totalChangesCount === 0}
|
||||
className="px-6"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm leading-5 text-mineshaft-400">
|
||||
Review pending changes and commit them to apply the updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Buttons */}
|
||||
<div className="ml-6 mt-0.5 flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => clearAllPendingChanges({ workspaceId, environment, secretPath })}
|
||||
isDisabled={totalChangesCount === 0}
|
||||
variant="outline_bg"
|
||||
className="px-4 hover:border-red/40 hover:bg-red/[0.1]"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
isDisabled={totalChangesCount === 0}
|
||||
className="px-6"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+18
@@ -384,6 +384,24 @@ const renderOutputForm = (
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.Couchbase) {
|
||||
const { username, password } = data as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OutputDisplay label="Username" value={username} />
|
||||
<OutputDisplay
|
||||
label="Password"
|
||||
value={password}
|
||||
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
+929
@@ -0,0 +1,929 @@
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
SecretInput,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
import { MetadataForm } from "../MetadataForm";
|
||||
|
||||
const BucketScopesConfiguration = ({
|
||||
control,
|
||||
bucketIndex,
|
||||
bucketsValue,
|
||||
setValue,
|
||||
addScope,
|
||||
removeScope,
|
||||
addCollection,
|
||||
removeCollection
|
||||
}: {
|
||||
control: any;
|
||||
bucketIndex: number;
|
||||
bucketsValue: any;
|
||||
setValue: any;
|
||||
addScope: (bucketIndex: number) => void;
|
||||
removeScope: (bucketIndex: number, scopeIndex: number) => void;
|
||||
addCollection: (bucketIndex: number, scopeIndex: number) => void;
|
||||
removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void;
|
||||
}) => {
|
||||
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
|
||||
const scopeFields = bucket?.scopes || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-mineshaft-300">Scopes</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => addScope(bucketIndex)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Scope
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopeFields.map((_scope: any, scopeIndex: number) => (
|
||||
<div
|
||||
key={`scope-${scopeIndex + 1}`}
|
||||
className="space-y-3 rounded border border-mineshaft-600 bg-mineshaft-700 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="plain"
|
||||
ariaLabel="Remove scope"
|
||||
size="sm"
|
||||
onClick={() => removeScope(bucketIndex, scopeIndex)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`inputs.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pl-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-mineshaft-300">Collections</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => addCollection(bucketIndex, scopeIndex)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Collection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopeFields[scopeIndex]?.collections?.map(
|
||||
(collection: string, collectionIndex: number) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FormControl className="flex-1">
|
||||
<Input
|
||||
value={collection || ""}
|
||||
onChange={(e) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||
currentBuckets[bucketIndex].scopes[scopeIndex].collections[
|
||||
collectionIndex
|
||||
] = e.target.value;
|
||||
setValue("inputs.buckets", currentBuckets);
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., airport, airline"
|
||||
className="text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="plain"
|
||||
ariaLabel="Remove collection"
|
||||
size="sm"
|
||||
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="mb-4 text-red-400" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{(!scopeFields[scopeIndex]?.collections ||
|
||||
scopeFields[scopeIndex].collections.length === 0) && (
|
||||
<div className="text-xs italic text-mineshaft-400">
|
||||
No collections specified (access to all collections in scope)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scopeFields.length === 0 && (
|
||||
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
|
||||
<p className="mb-2 text-xs text-mineshaft-400">
|
||||
No scopes configured (access to all scopes in bucket)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => addScope(bucketIndex)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Scope
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const couchbaseRoles = [
|
||||
{ value: "read", label: "Read", description: "Read-only access to bucket data" },
|
||||
{
|
||||
value: "write",
|
||||
label: "Write",
|
||||
description: "Full write access to bucket data"
|
||||
}
|
||||
];
|
||||
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(8, "Password must be at least 8 characters").max(128),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(1, "At least 1 lowercase character required"),
|
||||
uppercase: z.number().min(1, "At least 1 uppercase character required"),
|
||||
digits: z.number().min(1, "At least 1 digit required"),
|
||||
symbols: z.number().min(1, "At least 1 special character required")
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 128;
|
||||
}, "Sum of required characters cannot exceed 128"),
|
||||
allowedSymbols: z
|
||||
.string()
|
||||
.refine((symbols) => {
|
||||
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "�"];
|
||||
return !forbiddenChars.some((char) => symbols?.includes(char));
|
||||
}, "Cannot contain: < > ; . * &")
|
||||
.optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
|
||||
const bucketSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bucket name is required"),
|
||||
scopes: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, "Scope name is required"),
|
||||
collections: z.array(z.string().trim().min(1)).optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z
|
||||
.object({
|
||||
url: z.string().url().min(1),
|
||||
orgId: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
clusterId: z.string().min(1),
|
||||
roles: z.array(z.string()).min(1),
|
||||
buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]),
|
||||
useAdvancedBuckets: z.boolean().default(false),
|
||||
passwordRequirements: passwordRequirementsSchema.optional(),
|
||||
auth: z.object({
|
||||
apiKey: z.string().min(1)
|
||||
})
|
||||
})
|
||||
.partial(),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
})
|
||||
.nullable(),
|
||||
newName: slugSchema().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string().trim().default("")
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
usernameTemplate: z.string().trim().nullable().optional()
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const EditDynamicSecretCouchbaseForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
secretPath,
|
||||
environment,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL || undefined,
|
||||
newName: dynamicSecret.name,
|
||||
metadata: dynamicSecret.metadata,
|
||||
usernameTemplate: dynamicSecret.usernameTemplate,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as any),
|
||||
useAdvancedBuckets: Array.isArray((dynamicSecret.inputs as any)?.buckets)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
|
||||
const isAdvancedMode = watch("inputs.useAdvancedBuckets");
|
||||
const bucketsValue = watch("inputs.buckets");
|
||||
|
||||
const addBucket = () => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||
setValue("inputs.buckets", [...currentBuckets, { name: "", scopes: [] }]);
|
||||
};
|
||||
|
||||
const removeBucket = (index: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||
const newBuckets = currentBuckets.filter((_, i) => i !== index);
|
||||
setValue("inputs.buckets", newBuckets);
|
||||
};
|
||||
|
||||
const addScope = (bucketIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]) {
|
||||
const currentScopes = currentBuckets[bucketIndex].scopes || [];
|
||||
currentBuckets[bucketIndex] = {
|
||||
...currentBuckets[bucketIndex],
|
||||
scopes: [...currentScopes, { name: "", collections: [] }]
|
||||
};
|
||||
setValue("inputs.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const removeScope = (bucketIndex: number, scopeIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes) {
|
||||
currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter(
|
||||
(_, i) => i !== scopeIndex
|
||||
);
|
||||
setValue("inputs.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const addCollection = (bucketIndex: number, scopeIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]) {
|
||||
const currentCollections = currentBuckets[bucketIndex].scopes[scopeIndex].collections || [];
|
||||
currentBuckets[bucketIndex].scopes[scopeIndex].collections = [...currentCollections, ""];
|
||||
setValue("inputs.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => {
|
||||
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||
currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[
|
||||
bucketIndex
|
||||
].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex);
|
||||
setValue("inputs.buckets", currentBuckets);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateDynamicSecret = async ({
|
||||
inputs,
|
||||
newName,
|
||||
defaultTTL,
|
||||
maxTTL,
|
||||
metadata,
|
||||
usernameTemplate
|
||||
}: TForm) => {
|
||||
if (updateDynamicSecret.isPending) return;
|
||||
|
||||
const transformedInputs = inputs
|
||||
? {
|
||||
...inputs,
|
||||
buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string)
|
||||
}
|
||||
: inputs;
|
||||
|
||||
const finalInputs = transformedInputs
|
||||
? (() => {
|
||||
const { useAdvancedBuckets, ...rest } = transformedInputs;
|
||||
return rest;
|
||||
})()
|
||||
: transformedInputs;
|
||||
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
name: dynamicSecret.name,
|
||||
path: secretPath,
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
data: {
|
||||
defaultTTL,
|
||||
maxTTL: maxTTL || undefined,
|
||||
newName: newName === dynamicSecret.name ? undefined : newName,
|
||||
metadata,
|
||||
usernameTemplate:
|
||||
!usernameTemplate || usernameTemplate === "{{randomUsername}}"
|
||||
? undefined
|
||||
: usernameTemplate,
|
||||
inputs: finalInputs
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: `Failed to update dynamic secret: ${err}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="newName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MetadataForm control={control} />
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.url"
|
||||
defaultValue="https://cloudapi.cloud.couchbase.com"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="URL"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input placeholder="https://cloudapi.cloud.couchbase.com" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.orgId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Organization ID"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.projectId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Project ID"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.clusterId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster ID"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.roles"
|
||||
defaultValue={["read"]}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Roles"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Select one or more roles to assign to the user"
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
value={couchbaseRoles.filter((role) => value?.includes(role.value))}
|
||||
onChange={(selectedRoles) => {
|
||||
if (Array.isArray(selectedRoles)) {
|
||||
onChange(selectedRoles.map((role: any) => role.value));
|
||||
} else {
|
||||
onChange([]);
|
||||
}
|
||||
}}
|
||||
options={couchbaseRoles}
|
||||
placeholder="Select roles..."
|
||||
getOptionLabel={(option) => option.label}
|
||||
getOptionValue={(option) => option.value}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.useAdvancedBuckets"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl
|
||||
label="Advanced Bucket Configuration"
|
||||
helperText="Enable to configure specific buckets, scopes and collections. When disabled, '*' grants access to all buckets, scopes, and collections."
|
||||
>
|
||||
<Switch
|
||||
id="advanced-buckets-switch"
|
||||
isChecked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
const bucketsController = watch("inputs.buckets");
|
||||
if (checked && typeof bucketsController === "string") {
|
||||
setValue("inputs.buckets", []);
|
||||
} else if (!checked && Array.isArray(bucketsController)) {
|
||||
setValue("inputs.buckets", "*");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!watch("inputs.useAdvancedBuckets") && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.buckets"
|
||||
defaultValue="*"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bucket Access"
|
||||
className="w-full"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets, scopes, and collections"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={typeof field.value === "string" ? field.value : "*"}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdvancedMode && Array.isArray(bucketsValue) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-mineshaft-200">
|
||||
Advanced Bucket Configuration
|
||||
</div>
|
||||
<div className="text-sm text-mineshaft-400">
|
||||
Configure specific buckets with their scopes and collections. Leave scopes
|
||||
empty for access to all scopes in a bucket.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={addBucket}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Array.isArray(bucketsValue) &&
|
||||
(bucketsValue as any[]).map((_, bucketIndex) => (
|
||||
<div
|
||||
key={`bucket-${bucketIndex + 1}`}
|
||||
className="space-y-4 rounded border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-mineshaft-200">
|
||||
Bucket {bucketIndex + 1}
|
||||
</h4>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="plain"
|
||||
ariaLabel="Remove bucket"
|
||||
onClick={() => removeBucket(bucketIndex)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`inputs.buckets.${bucketIndex}.name`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bucket Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="e.g., travel-sample" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<BucketScopesConfiguration
|
||||
control={control}
|
||||
bucketIndex={bucketIndex}
|
||||
bucketsValue={bucketsValue}
|
||||
setValue={setValue}
|
||||
addScope={addScope}
|
||||
removeScope={removeScope}
|
||||
addCollection={addCollection}
|
||||
removeCollection={removeCollection}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && (
|
||||
<div className="rounded border border-dashed border-mineshaft-600 p-8 text-center">
|
||||
<p className="mb-2 text-sm text-mineshaft-400">No buckets configured</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={addBucket}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add First Bucket
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
name="inputs.auth.apiKey"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className="w-full"
|
||||
label="API Key"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
valueAlwaysHidden
|
||||
rows={1}
|
||||
wrap="hard"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="usernameTemplate"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username Template"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
placeholder="{{randomUsername}}"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="password-config">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Password Configuration (optional)</span>
|
||||
<Tooltip content="Couchbase password requirements: minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character. Cannot contain: < > ; . * & | �">
|
||||
<div className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-mineshaft-600 text-xs text-mineshaft-300">
|
||||
?
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Set constraints on the generated Couchbase user password (8-128 characters)
|
||||
<br />
|
||||
<span className="text-xs text-mineshaft-400">
|
||||
Forbidden characters: < > ; . * & | �
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.length"
|
||||
defaultValue={12}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password Length"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={8}
|
||||
max={128}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(() => {
|
||||
const total = Object.values(
|
||||
watch("inputs.passwordRequirements.required") || {}
|
||||
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const length = watch("inputs.passwordRequirements.length") || 0;
|
||||
const isError = total > length;
|
||||
return (
|
||||
<span className={isError ? "text-red-500" : ""}>
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.lowercase"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Lowercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min lowercase letters (required: e1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.uppercase"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Uppercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min uppercase letters (required: e1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.digits"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Digit Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min digits (required: e1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.symbols"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Symbol Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Min special characters (required: e1)"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Allowed Symbols</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.allowedSymbols"
|
||||
defaultValue="!@#$%^()_+-=[]{}:,?/~`"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Allowed Symbols"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Cannot contain: < > ; . * & | �"
|
||||
>
|
||||
<Input {...field} placeholder="!@#$%^()_+-=[]{}:,?/~`" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+18
@@ -8,6 +8,7 @@ import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecret
|
||||
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
|
||||
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
|
||||
@@ -384,6 +385,23 @@ export const EditDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.Couchbase && (
|
||||
<motion.div
|
||||
key="couchbase-edit"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<EditDynamicSecretCouchbaseForm
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
+7
-1
@@ -16,7 +16,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
||||
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
@@ -54,6 +54,7 @@ export const FolderListView = ({
|
||||
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
|
||||
select: (el) => el.secretPath
|
||||
});
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||
@@ -319,6 +320,11 @@ export const FolderListView = ({
|
||||
isOpen={popUp.deleteFolder.isOpen}
|
||||
deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name}
|
||||
title="Do you want to delete this folder?"
|
||||
subTitle={`This folder and all its contents will be removed. ${
|
||||
subscription?.pitRecovery
|
||||
? "You can reverse this action by rolling back to a previous commit."
|
||||
: "Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature."
|
||||
}`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||
onDeleteApproved={handleFolderDelete}
|
||||
/>
|
||||
|
||||
+4
-2
@@ -28,7 +28,7 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
||||
<h3 className="font-semibold text-mineshaft-100">Sync Logs</h3>
|
||||
{subscription.auditLogs && (
|
||||
<p className="text-xs text-bunker-300">
|
||||
Displaying audit logs from the last {auditLogsRetentionDays} days
|
||||
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -38,7 +38,9 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
||||
showFilters={false}
|
||||
presets={{
|
||||
eventMetadata: { syncId: secretSync.id },
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
||||
startDate: new Date(
|
||||
new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60))
|
||||
),
|
||||
eventType: INTEGRATION_EVENTS
|
||||
}}
|
||||
/>
|
||||
|
||||
+38
-11
@@ -1,23 +1,50 @@
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render";
|
||||
import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
import {
|
||||
useRenderConnectionListEnvironmentGroups,
|
||||
useRenderConnectionListServices
|
||||
} from "@app/hooks/api/appConnections/render";
|
||||
import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
|
||||
type Props = {
|
||||
secretSync: TRenderSync;
|
||||
};
|
||||
|
||||
export const RenderSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service;
|
||||
|
||||
const { data: services = [], isPending } = useRenderConnectionListServices(
|
||||
secretSync.connectionId
|
||||
secretSync.connectionId,
|
||||
{
|
||||
enabled: isServiceScope
|
||||
}
|
||||
);
|
||||
const {
|
||||
destinationConfig: { serviceId }
|
||||
} = secretSync;
|
||||
|
||||
if (isPending) {
|
||||
return <GenericFieldLabel label="Service">Loading...</GenericFieldLabel>;
|
||||
const { data: groups = [], isPending: isGroupsPending } =
|
||||
useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope });
|
||||
|
||||
switch (secretSync.destinationConfig.scope) {
|
||||
case RenderSyncScope.Service: {
|
||||
const id = secretSync.destinationConfig.serviceId;
|
||||
|
||||
if (isPending) {
|
||||
return <GenericFieldLabel label="Service">Loading...</GenericFieldLabel>;
|
||||
}
|
||||
|
||||
const serviceName = services.find((service) => service.id === id)?.name;
|
||||
return <GenericFieldLabel label="Service">{serviceName ?? id}</GenericFieldLabel>;
|
||||
}
|
||||
|
||||
case RenderSyncScope.EnvironmentGroup: {
|
||||
const id = secretSync.destinationConfig.environmentGroupId;
|
||||
|
||||
if (isGroupsPending) {
|
||||
return <GenericFieldLabel label="Environment Group">Loading...</GenericFieldLabel>;
|
||||
}
|
||||
|
||||
const envName = groups.find((g) => g.id === id)?.name;
|
||||
return <GenericFieldLabel label="Environment Group">{envName ?? id}</GenericFieldLabel>;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown render sync destination scope");
|
||||
}
|
||||
|
||||
const serviceName = services.find((service) => service.id === serviceId)?.name;
|
||||
return <GenericFieldLabel label="Service">{serviceName ?? serviceId}</GenericFieldLabel>;
|
||||
};
|
||||
|
||||
+37
-12
@@ -12,9 +12,15 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useUpdateWsEnvironment } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -35,6 +41,7 @@ type Props = {
|
||||
|
||||
export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const updateEnvironment = useUpdateWsEnvironment();
|
||||
|
||||
@@ -61,6 +68,16 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isMoreEnvironmentsAllowed =
|
||||
subscription?.environmentLimit && currentWorkspace?.environments
|
||||
? currentWorkspace.environments.length <= subscription.environmentLimit
|
||||
: true;
|
||||
|
||||
const environmentsOverPlanLimit =
|
||||
subscription?.environmentLimit && currentWorkspace?.environments
|
||||
? Math.max(0, currentWorkspace.environments.length - subscription.environmentLimit)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -122,18 +139,26 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
||||
a={ProjectPermissionSub.Environments}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("updateEnv", { name, slug, id });
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
<Tooltip
|
||||
content={
|
||||
isMoreEnvironmentsAllowed
|
||||
? ""
|
||||
: `You have exceeded the number of environments allowed by your plan. To edit an existing environment, either upgrade your plan or remove at least ${environmentsOverPlanLimit} environment${environmentsOverPlanLimit === 1 ? "" : "s"}.`
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("updateEnv", { name, slug, id });
|
||||
}}
|
||||
isDisabled={!isAllowed || !isMoreEnvironmentsAllowed}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
|
||||
Reference in New Issue
Block a user