Address greptile suggestions

This commit is contained in:
Carlos Monastyrski
2025-08-13 11:23:55 -07:00
parent 7cf9d933da
commit b479406ba0
6 changed files with 589 additions and 527 deletions
@@ -4,6 +4,7 @@ import axios from "axios";
import RE2 from "re2"; import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
@@ -27,34 +28,39 @@ type TCreateCouchbaseUser = {
}[]; }[];
}; };
type CouchbaseUserResponse = {
id: string;
uuid?: string;
};
const sanitizeCouchbaseUsername = (username: string): string => { const sanitizeCouchbaseUsername = (username: string): string => {
// Couchbase username restrictions: // Couchbase username restrictions:
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } { // - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
// - Cannot begin with @ character // - Cannot begin with @ character
const forbiddenCharsPattern = new RE2("[\\)\\(><,;:\"\\\\\\[\\]\\?=\\}\\{]", "g"); const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
let sanitized = forbiddenCharsPattern.replace(username, "-"); let sanitized = forbiddenCharsPattern.replace(username, "-");
const leadingAtPattern = new RE2("^@+"); const leadingAtPattern = new RE2("^@+");
sanitized = leadingAtPattern.replace(sanitized, ""); sanitized = leadingAtPattern.replace(sanitized, "");
if (!sanitized || sanitized.length === 0) { if (!sanitized || sanitized.length === 0) {
return alphaNumericNanoId(12); return alphaNumericNanoId(12);
} }
return sanitized; return sanitized;
}; };
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12); const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername); if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
const compiledUsername = compileUsernameTemplate({ const compiledUsername = compileUsernameTemplate({
usernameTemplate, usernameTemplate,
randomUsername, randomUsername,
identity identity
}); });
return sanitizeCouchbaseUsername(compiledUsername); return sanitizeCouchbaseUsername(compiledUsername);
}; };
@@ -74,26 +80,26 @@ const generatePassword = (requirements?: PasswordRequirements): string => {
let remaining = length; let remaining = length;
// Add required characters // Add required characters
for (let i = 0; i < required.lowercase; i++) { for (let i = 0; i < required.lowercase; i += 1) {
password += lowercase[crypto.randomInt(lowercase.length)]; password += lowercase[crypto.randomInt(lowercase.length)];
remaining--; remaining -= 1;
} }
for (let i = 0; i < required.uppercase; i++) { for (let i = 0; i < required.uppercase; i += 1) {
password += uppercase[crypto.randomInt(uppercase.length)]; password += uppercase[crypto.randomInt(uppercase.length)];
remaining--; remaining -= 1;
} }
for (let i = 0; i < required.digits; i++) { for (let i = 0; i < required.digits; i += 1) {
password += digits[crypto.randomInt(digits.length)]; password += digits[crypto.randomInt(digits.length)];
remaining--; remaining -= 1;
} }
for (let i = 0; i < required.symbols; i++) { for (let i = 0; i < required.symbols; i += 1) {
password += symbols[crypto.randomInt(symbols.length)]; password += symbols[crypto.randomInt(symbols.length)];
remaining--; remaining -= 1;
} }
// Fill remaining with random characters from all sets // Fill remaining with random characters from all sets
const allChars = lowercase + uppercase + digits + symbols; const allChars = lowercase + uppercase + digits + symbols;
for (let i = 0; i < remaining; i++) { for (let i = 0; i < remaining; i += 1) {
password += allChars[crypto.randomInt(allChars.length)]; password += allChars[crypto.randomInt(allChars.length)];
} }
@@ -109,99 +115,92 @@ const couchbaseApiRequest = async (
url: string, url: string,
apiKey: string, apiKey: string,
data?: unknown data?: unknown
): Promise<unknown> => { ): Promise<CouchbaseUserResponse> => {
await blockLocalAndPrivateIpAddresses(url); await blockLocalAndPrivateIpAddresses(url);
try { try {
const response = await axios({ const response = await axios({
method: method.toLowerCase() as any, method: method.toLowerCase() as "get" | "post" | "put" | "delete",
url, url,
headers: { headers: {
"Authorization": `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
data: data || undefined, data: data || undefined,
timeout: 30000 timeout: 30000
}); });
return response.data; return response.data as CouchbaseUserResponse;
} catch (error: any) { } catch (err) {
if (error.response) { const sanitizedErrorMessage = sanitizeString({
// Server responded with error status unsanitizedString: (err as Error)?.message,
const errorData = error.response.data; tokens: [apiKey]
const errorMessage = typeof errorData === 'string' ? errorData : JSON.stringify(errorData); });
throw new BadRequestError({ throw new BadRequestError({
message: `Couchbase API error: ${error.response.status} ${error.response.statusText} - ${errorMessage}` message: `Failed to connect with provider: ${sanitizedErrorMessage}`
}); });
} else if (error.request) {
// Request made but no response received
throw new BadRequestError({
message: `Couchbase API request failed: ${error.message}`
});
} else {
// Error in request configuration
throw new BadRequestError({
message: `Couchbase API request error: ${error.message}`
});
}
} }
}; };
export const CouchbaseProvider = (): TDynamicProviderFns => { export const CouchbaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: object) => { const validateProviderInputs = async (inputs: object) => {
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs); const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
await blockLocalAndPrivateIpAddresses(providerInputs.url); await blockLocalAndPrivateIpAddresses(providerInputs.url);
return providerInputs; return providerInputs;
}; };
const validateConnection = async (inputs: unknown): Promise<boolean> => { const validateConnection = async (inputs: unknown): Promise<boolean> => {
try { try {
const providerInputs = await validateProviderInputs(inputs as object); const providerInputs = await validateProviderInputs(inputs as object);
// Test connection by trying to get organization info // Test connection by trying to get organization info
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`; const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey); await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
return true; return true;
} catch (error) { } catch (error) {
throw new BadRequestError({ throw new BadRequestError({
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}` message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
}); });
} }
}; };
const create = async ({ const create = async ({
inputs, inputs,
usernameTemplate, usernameTemplate,
identity identity
}: { }: {
inputs: unknown; inputs: unknown;
usernameTemplate?: string | null; usernameTemplate?: string | null;
identity?: { name: string }; identity?: { name: string };
}) => { }) => {
const providerInputs = await validateProviderInputs(inputs as object); const providerInputs = await validateProviderInputs(inputs as object);
const username = generateUsername(usernameTemplate, identity); const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements); const password = generatePassword(providerInputs.passwordRequirements);
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`; const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
let bucketResources; let bucketResources;
if (typeof providerInputs.buckets === "string") { if (typeof providerInputs.buckets === "string") {
// Simple string format - either "*" or comma-separated bucket names // Simple string format - either "*" or comma-separated bucket names
const bucketNames = providerInputs.buckets === "*" const bucketNames =
? ["*"] providerInputs.buckets === "*"
: providerInputs.buckets.split(",").map(bucket => bucket.trim()).filter(bucket => bucket.length > 0); ? ["*"]
bucketResources = bucketNames.map(bucketName => ({ name: bucketName })); : providerInputs.buckets
.split(",")
.map((bucket) => bucket.trim())
.filter((bucket) => bucket.length > 0);
bucketResources = bucketNames.map((bucketName) => ({ name: bucketName }));
} else { } else {
// Array of bucket objects with scopes and collections // Array of bucket objects with scopes and collections
bucketResources = providerInputs.buckets.map(bucket => ({ bucketResources = providerInputs.buckets.map((bucket) => ({
name: bucket.name, name: bucket.name,
scopes: bucket.scopes?.map(scope => ({ scopes: bucket.scopes?.map((scope) => ({
name: scope.name, name: scope.name,
collections: scope.collections || [] collections: scope.collections || []
})) }))
@@ -211,18 +210,20 @@ export const CouchbaseProvider = (): TDynamicProviderFns => {
const userData: TCreateCouchbaseUser = { const userData: TCreateCouchbaseUser = {
name: username, name: username,
password, password,
access: [{ access: [
privileges: providerInputs.roles, {
resources: { privileges: providerInputs.roles,
buckets: bucketResources resources: {
buckets: bucketResources
}
} }
}] ]
}; };
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData) as any; const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
const userUuid = response?.id || response?.uuid || username; const userUuid = response?.id || response?.uuid || username;
return { return {
entityId: userUuid, entityId: userUuid,
data: { data: {
@@ -234,11 +235,11 @@ export const CouchbaseProvider = (): TDynamicProviderFns => {
const revoke = async (inputs: unknown, entityId: string) => { const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs as object); const providerInputs = await validateProviderInputs(inputs as object);
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`; const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey); await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
return { entityId }; return { entityId };
}; };
@@ -255,4 +256,4 @@ export const CouchbaseProvider = (): TDynamicProviderFns => {
revoke, revoke,
renew renew
}; };
}; };
@@ -511,16 +511,26 @@ export const DynamicSecretCouchbaseSchema = z.object({
projectId: z.string().trim().min(1).describe("Project ID"), projectId: z.string().trim().min(1).describe("Project ID"),
clusterId: z.string().trim().min(1).describe("Cluster 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"), roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z.union([ buckets: z
z.string().trim().min(1).default("*"), .union([
z.array(z.object({ z.string().trim().min(1).default("*"),
name: z.string().trim().min(1).describe("Bucket name"), z.array(
scopes: z.array(z.object({ z.object({
name: z.string().trim().min(1).describe("Scope name"), name: z.string().trim().min(1).describe("Bucket name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names") scopes: z
})).optional().describe("Scopes within the bucket") .array(
})) z.object({
]).default("*").describe("Bucket configuration: '*' for all buckets or array of bucket objects with scopes and collections"), 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")
})
)
])
.default("*")
.describe("Bucket configuration: '*' for all buckets or array of bucket objects with scopes and collections"),
passwordRequirements: z passwordRequirements: z
.object({ .object({
length: z.number().min(8, "Password must be at least 8 characters").max(128), length: z.number().min(8, "Password must be at least 8 characters").max(128),
@@ -535,10 +545,13 @@ export const DynamicSecretCouchbaseSchema = z.object({
const total = Object.values(data).reduce((sum, count) => sum + count, 0); const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128; return total <= 128;
}, "Sum of required characters cannot exceed 128"), }, "Sum of required characters cannot exceed 128"),
allowedSymbols: z.string().refine((symbols) => { allowedSymbols: z
const forbiddenChars = ['<', '>', ';', '.', '*', '&', '|', '£']; .string()
return !forbiddenChars.some(char => symbols?.includes(char)); .refine((symbols) => {
}, "Cannot contain: < > ; . * & | £").optional() const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * & | £")
.optional()
}) })
.refine((data) => { .refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
@@ -110,7 +110,7 @@ Create an API Key in your Couchbase Cloud following the [official documentation]
{{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX {{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX
{{unixTimestamp}} // 17490641580 {{unixTimestamp}} // 17490641580
{{identity.name}} // testuser {{identity.name}} // testuser
{{random-5}} // x9k2m {{random 5}} // x9k2m
{{truncate identity.name 4}} // test {{truncate identity.name 4}} // test
{{replace identity.name 'user' 'replace'}} // testreplace {{replace identity.name 'user' 'replace'}} // testreplace
``` ```
@@ -157,12 +157,12 @@ Create an API Key in your Couchbase Cloud following the [official documentation]
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. 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. 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-generate.png)
![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.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. 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) ![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png)
<Tip> <Tip>
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
@@ -170,7 +170,7 @@ Create an API Key in your Couchbase Cloud following the [official documentation]
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. 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) ![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png)
</Step> </Step>
</Steps> </Steps>
@@ -229,12 +229,12 @@ The advanced bucket configuration allows you to specify granular access control:
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. 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. 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) ![Provision Lease](../../../images/platform/dynamic-secrets/lease-data.png)
## Renew Leases ## 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. 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) ![Provision Lease](../../../images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning> <Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
@@ -1,7 +1,8 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
@@ -27,8 +28,8 @@ import { WorkspaceEnv } from "@app/hooks/api/types";
import { slugSchema } from "@app/lib/schemas"; import { slugSchema } from "@app/lib/schemas";
// Component for managing scopes and collections within a bucket // Component for managing scopes and collections within a bucket
const BucketScopesConfiguration = ({ const BucketScopesConfiguration = ({
control, control,
bucketIndex, bucketIndex,
bucketsValue, bucketsValue,
setValue, setValue,
@@ -36,8 +37,8 @@ const BucketScopesConfiguration = ({
removeScope, removeScope,
addCollection, addCollection,
removeCollection removeCollection
}: { }: {
control: any; control: any;
bucketIndex: number; bucketIndex: number;
bucketsValue: any; bucketsValue: any;
setValue: any; setValue: any;
@@ -49,7 +50,6 @@ const BucketScopesConfiguration = ({
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null; const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
const scopeFields = bucket?.scopes || []; const scopeFields = bucket?.scopes || [];
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -66,11 +66,12 @@ const BucketScopesConfiguration = ({
</div> </div>
{scopeFields.map((_scope: any, scopeIndex: number) => ( {scopeFields.map((_scope: any, scopeIndex: number) => (
<div key={scopeIndex} className="p-3 bg-mineshaft-700 rounded border border-mineshaft-600 space-y-3"> <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"> <div className="flex items-center justify-between">
<h5 className="text-xs font-medium text-mineshaft-200"> <h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
Scope {scopeIndex + 1}
</h5>
<IconButton <IconButton
type="button" type="button"
variant="plain" variant="plain"
@@ -81,22 +82,18 @@ const BucketScopesConfiguration = ({
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton> </IconButton>
</div> </div>
<Controller <Controller
control={control} control={control}
name={`provider.buckets.${bucketIndex}.scopes.${scopeIndex}.name`} name={`provider.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
label="Scope Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" /> <Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
</FormControl> </FormControl>
)} )}
/> />
<div className="pl-4 space-y-2"> <div className="space-y-2 pl-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs font-medium text-mineshaft-300">Collections</label> <label className="text-xs font-medium text-mineshaft-300">Collections</label>
<Button <Button
@@ -109,48 +106,53 @@ const BucketScopesConfiguration = ({
Add Collection Add Collection
</Button> </Button>
</div> </div>
{scopeFields[scopeIndex]?.collections?.map((collection: string, collectionIndex: number) => ( {scopeFields[scopeIndex]?.collections?.map(
<div key={`${bucketIndex}-${scopeIndex}-${collectionIndex}`} className="flex items-center space-x-2"> (collection: string, collectionIndex: number) => (
<FormControl className="flex-1"> <div key={collection} className="flex items-center space-x-2">
<Input <FormControl className="flex-1">
value={collection || ""} <Input
onChange={(e) => { value={collection || ""}
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; onChange={(e) => {
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
currentBuckets[bucketIndex].scopes[scopeIndex].collections[collectionIndex] = e.target.value; if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
setValue("provider.buckets", currentBuckets); currentBuckets[bucketIndex].scopes[scopeIndex].collections[
} collectionIndex
}} ] = e.target.value;
placeholder="e.g., airport, airline" setValue("provider.buckets", currentBuckets);
className="text-sm" }
/> }}
</FormControl> placeholder="e.g., airport, airline"
<IconButton className="text-sm"
type="button" />
variant="plain" </FormControl>
ariaLabel="Remove collection" <IconButton
className="mb-4" type="button"
size="sm" variant="plain"
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)} ariaLabel="Remove collection"
> className="mb-4"
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> size="sm"
</IconButton> onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
</div> >
))} <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton>
{(!scopeFields[scopeIndex]?.collections || scopeFields[scopeIndex].collections.length === 0) && ( </div>
<div className="text-xs text-mineshaft-400 italic"> )
)}
{(!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) No collections specified (access to all collections in scope)
</div> </div>
)} )}
</div> </div>
</div> </div>
))} ))}
{scopeFields.length === 0 && ( {scopeFields.length === 0 && (
<div className="p-4 text-center border border-dashed border-mineshaft-600 rounded bg-mineshaft-700"> <div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
<p className="text-xs text-mineshaft-400 mb-2"> <p className="mb-2 text-xs text-mineshaft-400">
No scopes configured (access to all scopes in bucket) No scopes configured (access to all scopes in bucket)
</p> </p>
<Button <Button
@@ -170,9 +172,21 @@ const BucketScopesConfiguration = ({
const couchbaseRoles = [ const couchbaseRoles = [
{ value: "data_reader", label: "Data Reader", description: "Read access to bucket data" }, { value: "data_reader", label: "Data Reader", description: "Read access to bucket data" },
{ value: "data_writer", label: "Data Writer", description: "Read and write access to bucket data" }, {
{ value: "read", label: "Read", description: "Read access to bucket data (alias for data_reader)" }, value: "data_writer",
{ value: "write", label: "Write", description: "Read and write access to bucket data (alias for data_writer)" } label: "Data Writer",
description: "Read and write access to bucket data"
},
{
value: "read",
label: "Read",
description: "Read access to bucket data (alias for data_reader)"
},
{
value: "write",
label: "Write",
description: "Read and write access to bucket data (alias for data_writer)"
}
]; ];
const passwordRequirementsSchema = z const passwordRequirementsSchema = z
@@ -189,10 +203,13 @@ const passwordRequirementsSchema = z
const total = Object.values(data).reduce((sum, count) => sum + count, 0); const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128; return total <= 128;
}, "Sum of required characters cannot exceed 128"), }, "Sum of required characters cannot exceed 128"),
allowedSymbols: z.string().refine((symbols) => { allowedSymbols: z
const forbiddenChars = ['<', '>', ';', '.', '*', '&', '|', '£']; .string()
return !forbiddenChars.some(char => symbols?.includes(char)); .refine((symbols) => {
}, "Cannot contain: < > ; . * & | £").optional() const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * & | £")
.optional()
}) })
.refine((data) => { .refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
@@ -201,10 +218,14 @@ const passwordRequirementsSchema = z
const bucketSchema = z.object({ const bucketSchema = z.object({
name: z.string().trim().min(1, "Bucket name is required"), name: z.string().trim().min(1, "Bucket name is required"),
scopes: z.array(z.object({ scopes: z
name: z.string().trim().min(1, "Scope name is required"), .array(
collections: z.array(z.string().trim().min(1)).optional() z.object({
})).optional() name: z.string().trim().min(1, "Scope name is required"),
collections: z.array(z.string().trim().min(1)).optional()
})
)
.optional()
}); });
const formSchema = z.object({ const formSchema = z.object({
@@ -214,14 +235,11 @@ const formSchema = z.object({
projectId: z.string().trim().min(1), projectId: z.string().trim().min(1),
clusterId: 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"), roles: z.array(z.string()).min(1, "At least one role must be selected"),
buckets: z.union([ buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]),
z.string().trim().min(1),
z.array(bucketSchema)
]),
useAdvancedBuckets: z.boolean().default(false), useAdvancedBuckets: z.boolean().default(false),
passwordRequirements: passwordRequirementsSchema.optional(), passwordRequirements: passwordRequirementsSchema.optional(),
auth: z.object({ auth: z.object({
apiKey: z.string().trim() apiKey: z.string().trim().min(1)
}) })
}), }),
defaultTTL: z.string().superRefine((val, ctx) => { defaultTTL: z.string().superRefine((val, ctx) => {
@@ -270,6 +288,7 @@ export const CouchbaseInputForm = ({
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit, handleSubmit,
setValue, setValue,
getValues,
watch watch
} = useForm<TForm>({ } = useForm<TForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -329,7 +348,9 @@ export const CouchbaseInputForm = ({
const removeScope = (bucketIndex: number, scopeIndex: number) => { const removeScope = (bucketIndex: number, scopeIndex: number) => {
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
if (currentBuckets[bucketIndex]?.scopes) { if (currentBuckets[bucketIndex]?.scopes) {
currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter((_, i) => i !== scopeIndex); currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter(
(_, i) => i !== scopeIndex
);
setValue("provider.buckets", currentBuckets); setValue("provider.buckets", currentBuckets);
} }
}; };
@@ -346,8 +367,9 @@ export const CouchbaseInputForm = ({
const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => { const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => {
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[
currentBuckets[bucketIndex].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex); bucketIndex
].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex);
setValue("provider.buckets", currentBuckets); setValue("provider.buckets", currentBuckets);
} }
}; };
@@ -362,14 +384,14 @@ export const CouchbaseInputForm = ({
}: TForm) => { }: TForm) => {
if (createDynamicSecret.isPending) return; if (createDynamicSecret.isPending) return;
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
const transformedProvider = { const transformedProvider = {
...provider, ...provider,
buckets: provider.useAdvancedBuckets ? provider.buckets : (provider.buckets as string) buckets: provider.useAdvancedBuckets ? provider.buckets : (provider.buckets as string)
}; };
const { useAdvancedBuckets, ...finalProvider } = transformedProvider; const { useAdvancedBuckets, ...finalProvider } = transformedProvider;
try { try {
await createDynamicSecret.mutateAsync({ await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Couchbase, inputs: finalProvider }, provider: { type: DynamicSecretProviders.Couchbase, inputs: finalProvider },
@@ -462,38 +484,19 @@ export const CouchbaseInputForm = ({
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
<Input <Input placeholder="https://cloudapi.cloud.couchbase.com" {...field} />
placeholder="https://cloudapi.cloud.couchbase.com"
{...field}
/>
</FormControl> </FormControl>
)} )}
/> />
</div> </div>
<div className="flex flex-col space-y-4"> <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 <Controller
control={control} control={control}
name="provider.projectId" name="provider.orgId"
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Project ID" label="Organization ID"
className="w-full" className="w-full"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
@@ -502,207 +505,225 @@ export const CouchbaseInputForm = ({
</FormControl> </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 <Controller
control={control} control={control}
name="provider.clusterId" name="provider.roles"
defaultValue="" defaultValue={["data_reader"]}
render={({ field, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Cluster ID" label="Roles"
className="w-full" className="w-full"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
helperText="Select one or more roles to assign to the user"
> >
<Input {...field} /> <FilterableSelect
</FormControl> isMulti
)} value={couchbaseRoles.filter((role) => value?.includes(role.value))}
/> onChange={(selectedRoles) => {
</div> if (Array.isArray(selectedRoles)) {
<Controller onChange(selectedRoles.map((role: any) => role.value));
control={control} } else {
name="provider.roles" onChange([]);
defaultValue={["data_reader"]} }
render={({ field: { value, onChange }, fieldState: { error } }) => ( }}
<FormControl options={couchbaseRoles}
label="Roles" placeholder="Select roles..."
className="w-full" getOptionLabel={(option) => option.label}
isError={Boolean(error?.message)} getOptionValue={(option) => option.value}
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 scopes and collections within buckets"
>
<Switch
id="advanced-buckets-switch"
isChecked={value}
onCheckedChange={(checked) => {
onChange(checked);
const bucketsController = control._formValues.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"
>
<Input
{...field}
value={typeof field.value === 'string' ? field.value : '*'}
onChange={(e) => field.onChange(e.target.value)}
placeholder="* (all buckets) or bucket1,bucket2,bucket3"
/> />
</FormControl> </FormControl>
)} )}
/> />
)}
{isAdvancedMode && Array.isArray(bucketsValue) && ( <Controller
<div className="space-y-4"> control={control}
<div className="flex items-center justify-between"> name="provider.useAdvancedBuckets"
<div> render={({ field: { value, onChange } }) => (
<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
</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={bucketIndex} className="p-4 border border-mineshaft-600 rounded bg-mineshaft-800 space-y-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="p-8 text-center border border-dashed border-mineshaft-600 rounded">
<p className="text-sm text-mineshaft-400 mb-2">
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 <FormControl
errorText={error?.message} label="Advanced Bucket Configuration"
isError={Boolean(error?.message)} helperText="Enable to configure specific scopes and collections within buckets"
className="w-full"
label="API Key"
> >
<SecretInput <Switch
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5" id="advanced-buckets-switch"
value={value} isChecked={value}
valueAlwaysHidden onCheckedChange={(checked) => {
rows={1} onChange(checked);
wrap="hard" const bucketsController = getValues("provider.buckets");
onChange={(e) => onChange(e.target.value)} if (checked && typeof bucketsController === "string") {
/> setValue("provider.buckets", []);
} else if (!checked && Array.isArray(bucketsController)) {
setValue("provider.buckets", "*");
}
}}
/>
</FormControl> </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"
>
<Input
{...field}
value={typeof field.value === "string" ? field.value : "*"}
onChange={(e) => field.onChange(e.target.value)}
placeholder="* (all buckets) 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
</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> </div>
@@ -731,7 +752,7 @@ export const CouchbaseInputForm = ({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>Password Configuration (optional)</span> <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: < > ; . * & | £"> <Tooltip content="Couchbase password requirements: minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character. Cannot contain: < > ; . * & | £">
<div className="h-4 w-4 rounded-full bg-mineshaft-600 text-xs text-mineshaft-300 flex items-center justify-center cursor-help"> <div className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-mineshaft-600 text-xs text-mineshaft-300">
? ?
</div> </div>
</Tooltip> </Tooltip>
@@ -750,7 +771,7 @@ export const CouchbaseInputForm = ({
<Controller <Controller
control={control} control={control}
name="provider.passwordRequirements.length" name="provider.passwordRequirements.length"
defaultValue={48} defaultValue={12}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Password Length" label="Password Length"
@@ -30,6 +30,7 @@ import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm"; import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm"; import { CassandraInputForm } from "./CassandraInputForm";
import { CouchbaseInputForm } from "./CouchbaseInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm"; import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { GcpIamInputForm } from "./GcpIamInputForm"; import { GcpIamInputForm } from "./GcpIamInputForm";
import { GithubInputForm } from "./GithubInputForm"; import { GithubInputForm } from "./GithubInputForm";
@@ -45,7 +46,6 @@ import { SnowflakeInputForm } from "./SnowflakeInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
import { TotpInputForm } from "./TotpInputForm"; import { TotpInputForm } from "./TotpInputForm";
import { VerticaInputForm } from "./VerticaInputForm"; import { VerticaInputForm } from "./VerticaInputForm";
import { CouchbaseInputForm } from "./CouchbaseInputForm";
type Props = { type Props = {
isOpen?: boolean; isOpen?: boolean;
@@ -615,7 +615,7 @@ export const CreateDynamicSecretForm = ({
/> />
</motion.div> </motion.div>
)} )}
{wizardStep === WizardSteps.ProviderInputs && {wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Couchbase && ( selectedProvider === DynamicSecretProviders.Couchbase && (
<motion.div <motion.div
key="dynamic-couchbase-step" key="dynamic-couchbase-step"
@@ -1,7 +1,8 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
@@ -27,8 +28,8 @@ import { slugSchema } from "@app/lib/schemas";
import { MetadataForm } from "../MetadataForm"; import { MetadataForm } from "../MetadataForm";
const BucketScopesConfiguration = ({ const BucketScopesConfiguration = ({
control, control,
bucketIndex, bucketIndex,
bucketsValue, bucketsValue,
setValue, setValue,
@@ -36,8 +37,8 @@ const BucketScopesConfiguration = ({
removeScope, removeScope,
addCollection, addCollection,
removeCollection removeCollection
}: { }: {
control: any; control: any;
bucketIndex: number; bucketIndex: number;
bucketsValue: any; bucketsValue: any;
setValue: any; setValue: any;
@@ -49,7 +50,6 @@ const BucketScopesConfiguration = ({
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null; const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
const scopeFields = bucket?.scopes || []; const scopeFields = bucket?.scopes || [];
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -66,11 +66,12 @@ const BucketScopesConfiguration = ({
</div> </div>
{scopeFields.map((_scope: any, scopeIndex: number) => ( {scopeFields.map((_scope: any, scopeIndex: number) => (
<div key={scopeIndex} className="p-3 bg-mineshaft-700 rounded border border-mineshaft-600 space-y-3"> <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"> <div className="flex items-center justify-between">
<h5 className="text-xs font-medium text-mineshaft-200"> <h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
Scope {scopeIndex + 1}
</h5>
<IconButton <IconButton
type="button" type="button"
variant="plain" variant="plain"
@@ -81,22 +82,18 @@ const BucketScopesConfiguration = ({
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton> </IconButton>
</div> </div>
<Controller <Controller
control={control} control={control}
name={`inputs.buckets.${bucketIndex}.scopes.${scopeIndex}.name`} name={`inputs.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
label="Scope Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" /> <Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
</FormControl> </FormControl>
)} )}
/> />
<div className="pl-4 space-y-2"> <div className="space-y-2 pl-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs font-medium text-mineshaft-300">Collections</label> <label className="text-xs font-medium text-mineshaft-300">Collections</label>
<Button <Button
@@ -109,47 +106,52 @@ const BucketScopesConfiguration = ({
Add Collection Add Collection
</Button> </Button>
</div> </div>
{scopeFields[scopeIndex]?.collections?.map((collection: string, collectionIndex: number) => ( {scopeFields[scopeIndex]?.collections?.map(
<div key={`${bucketIndex}-${scopeIndex}-${collectionIndex}`} className="flex items-center space-x-2"> (collection: string, collectionIndex: number) => (
<FormControl className="flex-1"> <div key={collection} className="flex items-center space-x-2">
<Input <FormControl className="flex-1">
value={collection || ""} <Input
onChange={(e) => { value={collection || ""}
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; onChange={(e) => {
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
currentBuckets[bucketIndex].scopes[scopeIndex].collections[collectionIndex] = e.target.value; if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
setValue("inputs.buckets", currentBuckets); currentBuckets[bucketIndex].scopes[scopeIndex].collections[
} collectionIndex
}} ] = e.target.value;
placeholder="e.g., airport, airline" setValue("inputs.buckets", currentBuckets);
className="text-sm" }
/> }}
</FormControl> placeholder="e.g., airport, airline"
<IconButton className="text-sm"
type="button" />
variant="plain" </FormControl>
ariaLabel="Remove collection" <IconButton
size="sm" type="button"
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)} variant="plain"
> ariaLabel="Remove collection"
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> size="sm"
</IconButton> onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
</div> >
))} <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton>
{(!scopeFields[scopeIndex]?.collections || scopeFields[scopeIndex].collections.length === 0) && ( </div>
<div className="text-xs text-mineshaft-400 italic"> )
)}
{(!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) No collections specified (access to all collections in scope)
</div> </div>
)} )}
</div> </div>
</div> </div>
))} ))}
{scopeFields.length === 0 && ( {scopeFields.length === 0 && (
<div className="p-4 text-center border border-dashed border-mineshaft-600 rounded bg-mineshaft-700"> <div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
<p className="text-xs text-mineshaft-400 mb-2"> <p className="mb-2 text-xs text-mineshaft-400">
No scopes configured (access to all scopes in bucket) No scopes configured (access to all scopes in bucket)
</p> </p>
<Button <Button
@@ -169,9 +171,21 @@ const BucketScopesConfiguration = ({
const couchbaseRoles = [ const couchbaseRoles = [
{ value: "data_reader", label: "Data Reader", description: "Read access to bucket data" }, { value: "data_reader", label: "Data Reader", description: "Read access to bucket data" },
{ value: "data_writer", label: "Data Writer", description: "Read and write access to bucket data" }, {
{ value: "read", label: "Read", description: "Read access to bucket data (alias for data_reader)" }, value: "data_writer",
{ value: "write", label: "Write", description: "Read and write access to bucket data (alias for data_writer)" } label: "Data Writer",
description: "Read and write access to bucket data"
},
{
value: "read",
label: "Read",
description: "Read access to bucket data (alias for data_reader)"
},
{
value: "write",
label: "Write",
description: "Read and write access to bucket data (alias for data_writer)"
}
]; ];
const passwordRequirementsSchema = z const passwordRequirementsSchema = z
@@ -188,10 +202,13 @@ const passwordRequirementsSchema = z
const total = Object.values(data).reduce((sum, count) => sum + count, 0); const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128; return total <= 128;
}, "Sum of required characters cannot exceed 128"), }, "Sum of required characters cannot exceed 128"),
allowedSymbols: z.string().refine((symbols) => { allowedSymbols: z
const forbiddenChars = ['<', '>', ';', '.', '*', '&', '|', '�']; .string()
return !forbiddenChars.some(char => symbols?.includes(char)); .refine((symbols) => {
}, "Cannot contain: < > ; . * & | �").optional() const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "�"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * &")
.optional()
}) })
.refine((data) => { .refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
@@ -200,10 +217,14 @@ const passwordRequirementsSchema = z
const bucketSchema = z.object({ const bucketSchema = z.object({
name: z.string().trim().min(1, "Bucket name is required"), name: z.string().trim().min(1, "Bucket name is required"),
scopes: z.array(z.object({ scopes: z
name: z.string().trim().min(1, "Scope name is required"), .array(
collections: z.array(z.string().trim().min(1)).optional() z.object({
})).optional() name: z.string().trim().min(1, "Scope name is required"),
collections: z.array(z.string().trim().min(1)).optional()
})
)
.optional()
}); });
const formSchema = z.object({ const formSchema = z.object({
@@ -214,10 +235,7 @@ const formSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
clusterId: z.string().min(1), clusterId: z.string().min(1),
roles: z.array(z.string()).min(1), roles: z.array(z.string()).min(1),
buckets: z.union([ buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]),
z.string().trim().min(1),
z.array(bucketSchema)
]),
useAdvancedBuckets: z.boolean().default(false), useAdvancedBuckets: z.boolean().default(false),
passwordRequirements: passwordRequirementsSchema.optional(), passwordRequirements: passwordRequirementsSchema.optional(),
auth: z.object({ auth: z.object({
@@ -287,7 +305,7 @@ export const EditDynamicSecretCouchbaseForm = ({
metadata: dynamicSecret.metadata, metadata: dynamicSecret.metadata,
usernameTemplate: dynamicSecret.usernameTemplate, usernameTemplate: dynamicSecret.usernameTemplate,
inputs: { inputs: {
...dynamicSecret.inputs as any, ...(dynamicSecret.inputs as any),
useAdvancedBuckets: Array.isArray((dynamicSecret.inputs as any)?.buckets) useAdvancedBuckets: Array.isArray((dynamicSecret.inputs as any)?.buckets)
} }
} }
@@ -324,7 +342,9 @@ export const EditDynamicSecretCouchbaseForm = ({
const removeScope = (bucketIndex: number, scopeIndex: number) => { const removeScope = (bucketIndex: number, scopeIndex: number) => {
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
if (currentBuckets[bucketIndex]?.scopes) { if (currentBuckets[bucketIndex]?.scopes) {
currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter((_, i) => i !== scopeIndex); currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter(
(_, i) => i !== scopeIndex
);
setValue("inputs.buckets", currentBuckets); setValue("inputs.buckets", currentBuckets);
} }
}; };
@@ -341,31 +361,36 @@ export const EditDynamicSecretCouchbaseForm = ({
const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => { const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => {
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[
currentBuckets[bucketIndex].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex); bucketIndex
].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex);
setValue("inputs.buckets", currentBuckets); setValue("inputs.buckets", currentBuckets);
} }
}; };
const handleUpdateDynamicSecret = async ({ const handleUpdateDynamicSecret = async ({
inputs, inputs,
newName, newName,
defaultTTL, defaultTTL,
maxTTL, maxTTL,
metadata, metadata,
usernameTemplate usernameTemplate
}: TForm) => { }: TForm) => {
if (updateDynamicSecret.isPending) return; if (updateDynamicSecret.isPending) return;
const transformedInputs = inputs ? { const transformedInputs = inputs
...inputs, ? {
buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string) ...inputs,
} : inputs; buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string)
}
const finalInputs = transformedInputs ? (() => { : inputs;
const { useAdvancedBuckets, ...rest } = transformedInputs;
return rest; const finalInputs = transformedInputs
})() : transformedInputs; ? (() => {
const { useAdvancedBuckets, ...rest } = transformedInputs;
return rest;
})()
: transformedInputs;
try { try {
await updateDynamicSecret.mutateAsync({ await updateDynamicSecret.mutateAsync({
@@ -378,7 +403,10 @@ export const EditDynamicSecretCouchbaseForm = ({
maxTTL: maxTTL || undefined, maxTTL: maxTTL || undefined,
newName: newName === dynamicSecret.name ? undefined : newName, newName: newName === dynamicSecret.name ? undefined : newName,
metadata, metadata,
usernameTemplate: !usernameTemplate || usernameTemplate === "{{randomUsername}}" ? undefined : usernameTemplate, usernameTemplate:
!usernameTemplate || usernameTemplate === "{{randomUsername}}"
? undefined
: usernameTemplate,
inputs: finalInputs inputs: finalInputs
} }
}); });
@@ -392,7 +420,7 @@ export const EditDynamicSecretCouchbaseForm = ({
} catch (err) { } catch (err) {
createNotification({ createNotification({
type: "error", type: "error",
text: "Failed to update dynamic secret" text: `Failed to update dynamic secret: ${err}`
}); });
} }
}; };
@@ -468,10 +496,7 @@ export const EditDynamicSecretCouchbaseForm = ({
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
<Input <Input placeholder="https://cloudapi.cloud.couchbase.com" {...field} />
placeholder="https://cloudapi.cloud.couchbase.com"
{...field}
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -534,7 +559,7 @@ export const EditDynamicSecretCouchbaseForm = ({
> >
<FilterableSelect <FilterableSelect
isMulti isMulti
value={couchbaseRoles.filter(role => value?.includes(role.value))} value={couchbaseRoles.filter((role) => value?.includes(role.value))}
onChange={(selectedRoles) => { onChange={(selectedRoles) => {
if (Array.isArray(selectedRoles)) { if (Array.isArray(selectedRoles)) {
onChange(selectedRoles.map((role: any) => role.value)); onChange(selectedRoles.map((role: any) => role.value));
@@ -558,7 +583,7 @@ export const EditDynamicSecretCouchbaseForm = ({
label="Advanced Bucket Configuration" label="Advanced Bucket Configuration"
helperText="Enable to configure specific scopes and collections within buckets" helperText="Enable to configure specific scopes and collections within buckets"
> >
<Switch <Switch
id="advanced-buckets-switch" id="advanced-buckets-switch"
isChecked={value} isChecked={value}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
@@ -588,9 +613,9 @@ export const EditDynamicSecretCouchbaseForm = ({
errorText={error?.message} errorText={error?.message}
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets" helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets"
> >
<Input <Input
{...field} {...field}
value={typeof field.value === 'string' ? field.value : '*'} value={typeof field.value === "string" ? field.value : "*"}
onChange={(e) => field.onChange(e.target.value)} onChange={(e) => field.onChange(e.target.value)}
placeholder="* (all buckets) or bucket1,bucket2,bucket3" placeholder="* (all buckets) or bucket1,bucket2,bucket3"
/> />
@@ -622,54 +647,56 @@ export const EditDynamicSecretCouchbaseForm = ({
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{Array.isArray(bucketsValue) && (bucketsValue as any[]).map((_, bucketIndex) => ( {Array.isArray(bucketsValue) &&
<div key={bucketIndex} className="p-4 border border-mineshaft-600 rounded bg-mineshaft-800 space-y-4"> (bucketsValue as any[]).map((_, bucketIndex) => (
<div className="flex items-center justify-between"> <div
<h4 className="text-sm font-medium text-mineshaft-200"> key={`bucket-${bucketIndex + 1}`}
Bucket {bucketIndex + 1} className="space-y-4 rounded border border-mineshaft-600 bg-mineshaft-800 p-4"
</h4> >
<IconButton <div className="flex items-center justify-between">
type="button" <h4 className="text-sm font-medium text-mineshaft-200">
variant="plain" Bucket {bucketIndex + 1}
ariaLabel="Remove bucket" </h4>
onClick={() => removeBucket(bucketIndex)} <IconButton
> type="button"
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> variant="plain"
</IconButton> ariaLabel="Remove bucket"
</div> onClick={() => removeBucket(bucketIndex)}
<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" /> <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</FormControl> </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>
))}
<BucketScopesConfiguration
control={control}
bucketIndex={bucketIndex}
bucketsValue={bucketsValue}
setValue={setValue}
addScope={addScope}
removeScope={removeScope}
addCollection={addCollection}
removeCollection={removeCollection}
/>
</div>
))}
{(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && ( {(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && (
<div className="p-8 text-center border border-dashed border-mineshaft-600 rounded"> <div className="rounded border border-dashed border-mineshaft-600 p-8 text-center">
<p className="text-sm text-mineshaft-400 mb-2"> <p className="mb-2 text-sm text-mineshaft-400">No buckets configured</p>
No buckets configured
</p>
<Button <Button
type="button" type="button"
variant="outline_bg" variant="outline_bg"
@@ -729,7 +756,7 @@ export const EditDynamicSecretCouchbaseForm = ({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>Password Configuration (optional)</span> <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: < > ; . * & | �"> <Tooltip content="Couchbase password requirements: minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character. Cannot contain: < > ; . * & | �">
<div className="h-4 w-4 rounded-full bg-mineshaft-600 text-xs text-mineshaft-300 flex items-center justify-center cursor-help"> <div className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-mineshaft-600 text-xs text-mineshaft-300">
? ?
</div> </div>
</Tooltip> </Tooltip>
@@ -904,4 +931,4 @@ export const EditDynamicSecretCouchbaseForm = ({
</form> </form>
</div> </div>
); );
}; };