Merge pull request #4372 from Infisical/ENG-3366

Add Couchbase dynamic secrets feature
This commit is contained in:
carlosmonastyrski
2025-08-13 20:36:23 -07:00
committed by GitHub
16 changed files with 2618 additions and 5 deletions

View File

@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import axios from "axios";
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
type TCreateCouchbaseUser = {
name: string;
password: string;
access: {
privileges: string[];
resources: {
buckets: {
name: string;
scopes?: {
name: string;
collections?: string[];
}[];
}[];
};
}[];
};
type CouchbaseUserResponse = {
id: string;
uuid?: string;
};
const sanitizeCouchbaseUsername = (username: string): string => {
// Couchbase username restrictions:
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
// - Cannot begin with @ character
const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
let sanitized = forbiddenCharsPattern.replace(username, "-");
const leadingAtPattern = new RE2("^@+");
sanitized = leadingAtPattern.replace(sanitized, "");
if (!sanitized || sanitized.length === 0) {
return alphaNumericNanoId(12);
}
return sanitized;
};
/**
* Normalizes bucket configuration to handle wildcard (*) access consistently.
*
* Key behaviors:
* - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections
*
* @param buckets - Either a string or array of bucket configurations
* @returns Normalized bucket resources for Couchbase API
*/
const normalizeBucketConfiguration = (
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>
) => {
if (typeof buckets === "string") {
// Simple string format - either "*" or comma-separated bucket names
const bucketNames = buckets
.split(",")
.map((bucket) => bucket.trim())
.filter((bucket) => bucket.length > 0);
// If "*" is present anywhere, grant access to all buckets, scopes, and collections
if (bucketNames.includes("*") || buckets === "*") {
return [{ name: "*" }];
}
return bucketNames.map((bucketName) => ({ name: bucketName }));
}
// Array of bucket objects with scopes and collections
// Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections
const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*");
if (hasWildcardBucket) {
return [{ name: "*" }];
}
return buckets.map((bucket) => ({
name: bucket.name,
scopes: bucket.scopes?.map((scope) => ({
name: scope.name,
collections: scope.collections || []
}))
}));
};
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
const compiledUsername = compileUsernameTemplate({
usernameTemplate,
randomUsername,
identity
});
return sanitizeCouchbaseUsername(compiledUsername);
};
const generatePassword = (requirements?: PasswordRequirements): string => {
const {
length = 12,
required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 },
allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`"
} = requirements || {};
const lowercase = "abcdefghijklmnopqrstuvwxyz";
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits = "0123456789";
const symbols = allowedSymbols;
let password = "";
let remaining = length;
// Add required characters
for (let i = 0; i < required.lowercase; i += 1) {
password += lowercase[crypto.randomInt(lowercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.uppercase; i += 1) {
password += uppercase[crypto.randomInt(uppercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.digits; i += 1) {
password += digits[crypto.randomInt(digits.length)];
remaining -= 1;
}
for (let i = 0; i < required.symbols; i += 1) {
password += symbols[crypto.randomInt(symbols.length)];
remaining -= 1;
}
// Fill remaining with random characters from all sets
const allChars = lowercase + uppercase + digits + symbols;
for (let i = 0; i < remaining; i += 1) {
password += allChars[crypto.randomInt(allChars.length)];
}
// Shuffle the password
return password
.split("")
.sort(() => crypto.randomInt(3) - 1)
.join("");
};
const couchbaseApiRequest = async (
method: string,
url: string,
apiKey: string,
data?: unknown
): Promise<CouchbaseUserResponse> => {
await blockLocalAndPrivateIpAddresses(url);
try {
const response = await axios({
method: method.toLowerCase() as "get" | "post" | "put" | "delete",
url,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
data: data || undefined,
timeout: 30000
});
return response.data as CouchbaseUserResponse;
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [apiKey]
});
throw new BadRequestError({
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
});
}
};
export const CouchbaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: object) => {
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
await blockLocalAndPrivateIpAddresses(providerInputs.url);
return providerInputs;
};
const validateConnection = async (inputs: unknown): Promise<boolean> => {
try {
const providerInputs = await validateProviderInputs(inputs as object);
// Test connection by trying to get organization info
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
return true;
} catch (error) {
throw new BadRequestError({
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const create = async ({
inputs,
usernameTemplate,
identity
}: {
inputs: unknown;
usernameTemplate?: string | null;
identity?: { name: string };
}) => {
const providerInputs = await validateProviderInputs(inputs as object);
const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements);
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
const userData: TCreateCouchbaseUser = {
name: username,
password,
access: [
{
privileges: providerInputs.roles,
resources: {
buckets: bucketResources
}
}
]
};
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
const userUuid = response?.id || response?.uuid || username;
return {
entityId: userUuid,
data: {
username,
password
}
};
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs as object);
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
return { entityId };
};
const renew = async (_inputs: unknown, entityId: string) => {
// Couchbase Cloud API doesn't support renewing user credentials
// The user remains valid until explicitly deleted
return { entityId };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};

View File

@@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra";
import { CouchbaseProvider } from "./couchbase";
import { ElasticSearchProvider } from "./elastic-search";
import { GcpIamProvider } from "./gcp-iam";
import { GithubProvider } from "./github";
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
[DynamicSecretProviders.GcpIam]: GcpIamProvider(),
[DynamicSecretProviders.Github]: GithubProvider()
[DynamicSecretProviders.Github]: GithubProvider(),
[DynamicSecretProviders.Couchbase]: CouchbaseProvider()
});

View File

@@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({
.describe("The private key generated for your GitHub App.")
});
export const DynamicSecretCouchbaseSchema = z.object({
url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"),
orgId: z.string().trim().min(1).describe("Organization ID"),
projectId: z.string().trim().min(1).describe("Project ID"),
clusterId: z.string().trim().min(1).describe("Cluster ID"),
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z
.union([
z
.string()
.trim()
.min(1)
.default("*")
.refine((val) => {
if (val.includes(",")) {
const buckets = val
.split(",")
.map((b) => b.trim())
.filter((b) => b.length > 0);
if (buckets.includes("*") && buckets.length > 1) {
return false;
}
}
return true;
}, "Cannot combine '*' with other bucket names"),
z
.array(
z.object({
name: z.string().trim().min(1).describe("Bucket name"),
scopes: z
.array(
z.object({
name: z.string().trim().min(1).describe("Scope name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names")
})
)
.optional()
.describe("Scopes within the bucket")
})
)
.refine((buckets) => {
const hasWildcard = buckets.some((bucket) => bucket.name === "*");
if (hasWildcard && buckets.length > 1) {
return false;
}
return true;
}, "Cannot combine '*' bucket with other buckets")
])
.default("*")
.describe(
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
),
passwordRequirements: 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")
.optional()
.describe("Password generation requirements for Couchbase"),
auth: z.object({
apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key")
})
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes",
Vertica = "vertica",
GcpIam = "gcp-iam",
Github = "github"
Github = "github",
Couchbase = "couchbase"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema })
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema })
]);
export type TDynamicProviderFns = {

View File

@@ -439,6 +439,7 @@
"documentation/platform/dynamic-secrets/aws-iam",
"documentation/platform/dynamic-secrets/azure-entra-id",
"documentation/platform/dynamic-secrets/cassandra",
"documentation/platform/dynamic-secrets/couchbase",
"documentation/platform/dynamic-secrets/elastic-search",
"documentation/platform/dynamic-secrets/gcp-iam",
"documentation/platform/dynamic-secrets/github",

View File

@@ -0,0 +1,259 @@
---
title: "Couchbase"
description: "Learn how to dynamically generate Couchbase Database user credentials."
---
The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions.
## Prerequisite
Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key).
<Info>The API Key must have permission to manage database users in your Couchbase Cloud organization and project.</Info>
## Set up Dynamic Secrets with Couchbase
<Steps>
<Step title="Open Secret Overview Dashboard">
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select Couchbase">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret
</ParamField>
<ParamField path="URL" type="string" required default="https://cloudapi.cloud.couchbase.com">
The Couchbase Cloud API URL
</ParamField>
<ParamField path="Organization ID" type="string" required>
Your Couchbase Cloud organization ID
</ParamField>
<ParamField path="Project ID" type="string" required>
Your Couchbase Cloud project ID
</ParamField>
<ParamField path="Cluster ID" type="string" required>
Your Couchbase Cloud cluster ID where users will be created
</ParamField>
<ParamField path="Roles" type="array" required>
Database credential roles to assign to the generated user. Available options:
- **read**: Read access to bucket data (alias for data_reader)
- **write**: Read and write access to bucket data (alias for data_writer)
</ParamField>
<ParamField path="Bucket Access" type="string" required default="*">
Specify bucket access configuration:
- Use `*` for access to all buckets
- Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets
- Use Advanced Bucket Configuration for granular scope and collection access
</ParamField>
<ParamField path="API Key" type="string" required>
Your Couchbase Cloud API Key for authentication
</ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png)
</Step>
<Step title="(Optional) Advanced Configuration">
![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png)
<ParamField path="Advanced Bucket Configuration" type="boolean" default="false">
Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections
</ParamField>
When Advanced Bucket Configuration is enabled, you can configure:
<ParamField path="Buckets" type="array">
List of buckets with optional scope and collection specifications:
- **Bucket Name**: Name of the bucket (e.g., travel-sample)
- **Scopes**: Optional array of scopes within the bucket
- **Scope Name**: Name of the scope (e.g., inventory, _default)
- **Collections**: Optional array of collection names within the scope
</ParamField>
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
Specifies a template for generating usernames. This field allows customization of how usernames are automatically created.
Allowed template variables are:
- `{{randomUsername}}`: Random username string
- `{{unixTimestamp}}`: Current Unix timestamp
- `{{identity.name}}`: Name of the identity that is generating the secret
- `{{random N}}`: Random string of N characters
Allowed template functions are:
- `truncate`: Truncates a string to a specified length
- `replace`: Replaces a substring with another value
Examples:
```
{{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX
{{unixTimestamp}} // 17490641580
{{identity.name}} // testuser
{{random 5}} // x9k2m
{{truncate identity.name 4}} // test
{{replace identity.name 'user' 'replace'}} // testreplace
```
</ParamField>
<ParamField path="Password Configuration" type="object">
Optional password generation requirements for Couchbase users:
<ParamField path="Password Length" type="number" default="12" min="8" max="128">
Length of the generated password
</ParamField>
<ParamField path="Character Requirements" type="object">
Minimum required character counts:
- **Lowercase Count**: Minimum lowercase letters (default: 1)
- **Uppercase Count**: Minimum uppercase letters (default: 1)
- **Digit Count**: Minimum digits (default: 1)
- **Symbol Count**: Minimum special characters (default: 1)
</ParamField>
<ParamField path="Allowed Symbols" type="string" default="!@#$%^()_+-=[]{}:,?/~`">
Special characters allowed in passwords. Cannot contain: `< > ; . * & | £`
</ParamField>
<Info>
Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £`
</Info>
</ParamField>
</Step>
<Step title="Click 'Submit'">
After submitting the form, you will see a dynamic secret created in the dashboard.
<Note>
If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs.
</Note>
![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png)
</Step>
<Step title="Generate dynamic secrets">
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-generate.png)
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-lease-empty.png)
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png)
<Tip>
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
</Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png)
</Step>
</Steps>
## Advanced Bucket Configuration Examples
The advanced bucket configuration allows you to specify granular access control:
### Example 1: Specific Bucket Access
```json
[
{
"name": "travel-sample"
}
]
```
### Example 2: Bucket with Specific Scopes
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory"
},
{
"name": "_default"
}
]
}
]
```
### Example 3: Bucket with Scopes and Collections
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory",
"collections": ["airport", "airline"]
},
{
"name": "_default",
"collections": ["users"]
}
]
}
]
```
## Audit or Revoke Leases
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
This will allow you to see the expiration time of the lease or delete a lease before its set time to live.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-data.png)
## Renew Leases
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below.
![Provision Lease](../../../images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>
## Couchbase Roles and Permissions
The Couchbase dynamic secret integration supports the following database credential roles:
- **read**: Provides read-only access to bucket data
- **write**: Provides read and write access to bucket data
<Note>
These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration.
</Note>
## Troubleshooting
### Common Issues
1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users
2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key
3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write)
4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => {
);
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
onSuccess: (_, { path, environmentSlug, projectSlug, name }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
queryClient.invalidateQueries({
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
});
queryClient.invalidateQueries({
queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name })
});
}
});
};

View File

@@ -37,7 +37,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes",
Vertica = "vertica",
GcpIam = "gcp-iam",
Github = "github"
Github = "github",
Couchbase = "couchbase"
}
export enum KubernetesDynamicSecretCredentialType {
@@ -353,6 +354,38 @@ export type TDynamicSecretProvider =
installationId: number;
privateKey: string;
};
}
| {
type: DynamicSecretProviders.Couchbase;
inputs: {
url: string;
orgId: string;
projectId: string;
clusterId: string;
roles: string[];
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>;
passwordRequirements?: {
length: number;
required: {
lowercase: number;
uppercase: number;
digits: number;
symbols: number;
};
allowedSymbols?: string;
};
auth: {
apiKey: string;
};
};
};
export type TCreateDynamicSecretDTO = {

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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;
};

View File

@@ -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 = ["<", ">", ";", ".", "*", "&", "|", "<22>"];
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: < > ; . * & | <20>">
<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; | <EFBFBD>
</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: < > ; . * & | <20>"
>
<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>
);
};

View File

@@ -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>
);
};