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,12 +28,17 @@ 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("^@+");
@@ -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,41 +115,30 @@ 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({
message: `Couchbase API error: ${error.response.status} ${error.response.statusText} - ${errorMessage}`
}); });
} else if (error.request) {
// Request made but no response received
throw new BadRequestError({ throw new BadRequestError({
message: `Couchbase API request failed: ${error.message}` message: `Failed to connect with provider: ${sanitizedErrorMessage}`
}); });
} else {
// Error in request configuration
throw new BadRequestError({
message: `Couchbase API request error: ${error.message}`
});
}
} }
}; };
@@ -193,15 +188,19 @@ export const CouchbaseProvider = (): TDynamicProviderFns => {
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); : providerInputs.buckets
bucketResources = bucketNames.map(bucketName => ({ name: bucketName })); .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,15 +210,17 @@ export const CouchbaseProvider = (): TDynamicProviderFns => {
const userData: TCreateCouchbaseUser = { const userData: TCreateCouchbaseUser = {
name: username, name: username,
password, password,
access: [{ access: [
{
privileges: providerInputs.roles, privileges: providerInputs.roles,
resources: { resources: {
buckets: bucketResources 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;
@@ -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
.union([
z.string().trim().min(1).default("*"), z.string().trim().min(1).default("*"),
z.array(z.object({ z.array(
z.object({
name: z.string().trim().min(1).describe("Bucket name"), name: z.string().trim().min(1).describe("Bucket name"),
scopes: z.array(z.object({ scopes: z
.array(
z.object({
name: z.string().trim().min(1).describe("Scope name"), name: z.string().trim().min(1).describe("Scope name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names") 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"), .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";
@@ -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"
@@ -86,17 +87,13 @@ const BucketScopesConfiguration = ({
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
@@ -110,15 +107,18 @@ const BucketScopesConfiguration = ({
</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) => (
<div key={collection} className="flex items-center space-x-2">
<FormControl className="flex-1"> <FormControl className="flex-1">
<Input <Input
value={collection || ""} value={collection || ""}
onChange={(e) => { onChange={(e) => {
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[collectionIndex] = e.target.value; currentBuckets[bucketIndex].scopes[scopeIndex].collections[
collectionIndex
] = e.target.value;
setValue("provider.buckets", currentBuckets); setValue("provider.buckets", currentBuckets);
} }
}} }}
@@ -137,10 +137,12 @@ const BucketScopesConfiguration = ({
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton> </IconButton>
</div> </div>
))} )
)}
{(!scopeFields[scopeIndex]?.collections || scopeFields[scopeIndex].collections.length === 0) && ( {(!scopeFields[scopeIndex]?.collections ||
<div className="text-xs text-mineshaft-400 italic"> 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>
)} )}
@@ -149,8 +151,8 @@ const BucketScopesConfiguration = ({
))} ))}
{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
.array(
z.object({
name: z.string().trim().min(1, "Scope name is required"), name: z.string().trim().min(1, "Scope name is required"),
collections: z.array(z.string().trim().min(1)).optional() collections: z.array(z.string().trim().min(1)).optional()
})).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);
} }
}; };
@@ -462,10 +484,7 @@ 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>
)} )}
/> />
@@ -532,7 +551,7 @@ export const CouchbaseInputForm = ({
> >
<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));
@@ -562,7 +581,7 @@ export const CouchbaseInputForm = ({
isChecked={value} isChecked={value}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
onChange(checked); onChange(checked);
const bucketsController = control._formValues.provider.buckets; const bucketsController = getValues("provider.buckets");
if (checked && typeof bucketsController === "string") { if (checked && typeof bucketsController === "string") {
setValue("provider.buckets", []); setValue("provider.buckets", []);
} else if (!checked && Array.isArray(bucketsController)) { } else if (!checked && Array.isArray(bucketsController)) {
@@ -589,7 +608,7 @@ export const CouchbaseInputForm = ({
> >
<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"
/> />
@@ -621,8 +640,12 @@ export const CouchbaseInputForm = ({
</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
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"> <div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-mineshaft-200"> <h4 className="text-sm font-medium text-mineshaft-200">
Bucket {bucketIndex + 1} Bucket {bucketIndex + 1}
@@ -665,10 +688,8 @@ export const CouchbaseInputForm = ({
))} ))}
{(!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"
@@ -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;
@@ -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";
@@ -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"
@@ -86,17 +87,13 @@ const BucketScopesConfiguration = ({
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
@@ -110,15 +107,18 @@ const BucketScopesConfiguration = ({
</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) => (
<div key={collection} className="flex items-center space-x-2">
<FormControl className="flex-1"> <FormControl className="flex-1">
<Input <Input
value={collection || ""} value={collection || ""}
onChange={(e) => { onChange={(e) => {
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[collectionIndex] = e.target.value; currentBuckets[bucketIndex].scopes[scopeIndex].collections[
collectionIndex
] = e.target.value;
setValue("inputs.buckets", currentBuckets); setValue("inputs.buckets", currentBuckets);
} }
}} }}
@@ -136,10 +136,12 @@ const BucketScopesConfiguration = ({
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> <FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton> </IconButton>
</div> </div>
))} )
)}
{(!scopeFields[scopeIndex]?.collections || scopeFields[scopeIndex].collections.length === 0) && ( {(!scopeFields[scopeIndex]?.collections ||
<div className="text-xs text-mineshaft-400 italic"> 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>
)} )}
@@ -148,8 +150,8 @@ const BucketScopesConfiguration = ({
))} ))}
{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
.array(
z.object({
name: z.string().trim().min(1, "Scope name is required"), name: z.string().trim().min(1, "Scope name is required"),
collections: z.array(z.string().trim().min(1)).optional() collections: z.array(z.string().trim().min(1)).optional()
})).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,8 +361,9 @@ 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);
} }
}; };
@@ -357,15 +378,19 @@ export const EditDynamicSecretCouchbaseForm = ({
}: TForm) => { }: TForm) => {
if (updateDynamicSecret.isPending) return; if (updateDynamicSecret.isPending) return;
const transformedInputs = inputs ? { const transformedInputs = inputs
? {
...inputs, ...inputs,
buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string) buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string)
} : inputs; }
: inputs;
const finalInputs = transformedInputs ? (() => { const finalInputs = transformedInputs
? (() => {
const { useAdvancedBuckets, ...rest } = transformedInputs; const { useAdvancedBuckets, ...rest } = transformedInputs;
return rest; return rest;
})() : transformedInputs; })()
: 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));
@@ -590,7 +615,7 @@ export const EditDynamicSecretCouchbaseForm = ({
> >
<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,8 +647,12 @@ 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
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"> <div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-mineshaft-200"> <h4 className="text-sm font-medium text-mineshaft-200">
Bucket {bucketIndex + 1} Bucket {bucketIndex + 1}
@@ -666,10 +695,8 @@ export const EditDynamicSecretCouchbaseForm = ({
))} ))}
{(!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>