Merge branch 'heads/main' into daniel/scim-deprovisioning-ui

This commit is contained in:
Daniel Hougaard
2025-08-25 16:56:09 +02:00
156 changed files with 6558 additions and 1109 deletions
@@ -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
}}
/>
@@ -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} />;
};
@@ -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;
@@ -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={
@@ -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}
/>
)}
@@ -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>
);
};
@@ -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">
@@ -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">
@@ -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: &lt; &gt; ; . * &amp; | £
</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>
);
};
@@ -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>
@@ -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>
)}
@@ -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;
};
@@ -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: &lt; &gt; ; . * &amp; | �
</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>
);
};
@@ -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>
);
};
@@ -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}
/>
@@ -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
}}
/>
@@ -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>;
};
@@ -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