mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 11:38:55 +00:00
feature: access tree
This commit is contained in:
@@ -228,7 +228,8 @@ export const OverviewPage = () => {
|
||||
setPage
|
||||
});
|
||||
|
||||
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
|
||||
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } =
|
||||
useFolderOverview(folders);
|
||||
|
||||
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
|
||||
useDynamicSecretOverview(dynamicSecrets);
|
||||
@@ -251,7 +252,7 @@ export const OverviewPage = () => {
|
||||
"updateFolder"
|
||||
] as const);
|
||||
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
const promises = userAvailableEnvs.map((env) => {
|
||||
const environment = env.slug;
|
||||
return createFolder({
|
||||
@@ -1029,7 +1030,7 @@ export const OverviewPage = () => {
|
||||
)}
|
||||
{!isOverviewLoading && visibleEnvs.length > 0 && (
|
||||
<>
|
||||
{folderNamesAndDescriptions.map(({name: folderName, description}, index) => (
|
||||
{folderNamesAndDescriptions.map(({ name: folderName, description }, index) => (
|
||||
<SecretOverviewFolderRow
|
||||
folderName={folderName}
|
||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||
@@ -1161,7 +1162,9 @@ export const OverviewPage = () => {
|
||||
<FolderForm
|
||||
isEdit
|
||||
defaultFolderName={(popUp.updateFolder?.data as Pick<TSecretFolder, "name">)?.name}
|
||||
defaultDescription={(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description}
|
||||
defaultDescription={
|
||||
(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description
|
||||
}
|
||||
onUpdateFolder={handleFolderUpdate}
|
||||
showDescriptionOverwriteWarning
|
||||
/>
|
||||
|
||||
+1
-1
@@ -16,10 +16,10 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
const typeSchema = z
|
||||
.object({
|
||||
|
||||
+47
-38
@@ -23,22 +23,26 @@ import { useWorkspace } from "@app/context";
|
||||
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
const passwordRequirementsSchema = z.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
}).refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250;
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
}).refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250;
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
@@ -166,7 +170,7 @@ export const SqlDatabaseInputForm = ({
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: '-_.~!*'
|
||||
allowedSymbols: "-_.~!*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +209,7 @@ export const SqlDatabaseInputForm = ({
|
||||
setValue("provider.renewStatement", sqlStatment.renewStatement);
|
||||
setValue("provider.revocationStatement", sqlStatment.revocationStatement);
|
||||
setValue("provider.port", getDefaultPort(type));
|
||||
|
||||
|
||||
// Update password requirements based on provider
|
||||
const length = type === SqlProviders.Oracle ? 30 : 48;
|
||||
setValue("provider.passwordRequirements.length", length);
|
||||
@@ -424,7 +428,9 @@ export const SqlDatabaseInputForm = ({
|
||||
/>
|
||||
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>Creation, Revocation & Renew Statements (optional)</AccordionTrigger>
|
||||
<AccordionTrigger>
|
||||
Creation, Revocation & Renew Statements (optional)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Customize SQL statements for managing database user lifecycle
|
||||
@@ -508,10 +514,10 @@ export const SqlDatabaseInputForm = ({
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -519,17 +525,20 @@ export const SqlDatabaseInputForm = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(() => {
|
||||
const total = Object.values(watch("provider.passwordRequirements.required") || {}).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const total = Object.values(
|
||||
watch("provider.passwordRequirements.required") || {}
|
||||
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const length = watch("provider.passwordRequirements.length") || 0;
|
||||
const isError = total > length;
|
||||
return (
|
||||
<span className={isError ? "text-red-500" : ""}>
|
||||
Total required characters: {total} {isError ? `(exceeds length of ${length})` : ""}
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
@@ -546,9 +555,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -566,9 +575,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -586,9 +595,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -606,9 +615,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
+30
-29
@@ -15,7 +15,8 @@ type Props = {
|
||||
showDescriptionOverwriteWarning?: boolean;
|
||||
};
|
||||
|
||||
const descriptionOverwriteWarningMessage = "Warning: Any changes made here will overwrite any custom edits in individual environment folders."
|
||||
const descriptionOverwriteWarningMessage =
|
||||
"Warning: Any changes made here will overwrite any custom edits in individual environment folders.";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
@@ -25,9 +26,7 @@ const formSchema = z.object({
|
||||
/^[a-zA-Z0-9-_]+$/,
|
||||
"Folder name can only contain letters, numbers, dashes, and underscores"
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
description: z.string().optional()
|
||||
});
|
||||
type TFormData = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -59,7 +58,7 @@ export const FolderForm = ({
|
||||
if (textarea) {
|
||||
const lines = textarea.value.split("\n");
|
||||
const maxDescriptionLines = 10;
|
||||
|
||||
|
||||
if (lines.length > maxDescriptionLines) {
|
||||
textarea.value = lines.slice(0, maxDescriptionLines).join("\n");
|
||||
}
|
||||
@@ -90,30 +89,32 @@ export const FolderForm = ({
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Folder Description"
|
||||
isError={Boolean(error)}
|
||||
tooltipText={showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined}
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Folder description"
|
||||
{...field}
|
||||
rows={3}
|
||||
ref={descriptionRef}
|
||||
onInput={handleInput}
|
||||
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
control={control}
|
||||
name="description"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Folder Description"
|
||||
isError={Boolean(error)}
|
||||
tooltipText={
|
||||
showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined
|
||||
}
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Folder description"
|
||||
{...field}
|
||||
rows={3}
|
||||
ref={descriptionRef}
|
||||
onInput={handleInput}
|
||||
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
{isEdit ? "Save" : "Create"}
|
||||
|
||||
+51
-39
@@ -23,22 +23,26 @@ import { useWorkspace } from "@app/context";
|
||||
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
const passwordRequirementsSchema = z.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
}).refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250; // Sanity check for individual validation
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
}).refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250; // Sanity check for individual validation
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z
|
||||
@@ -108,7 +112,7 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: '-_.~!*'
|
||||
allowedSymbols: "-_.~!*"
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -124,8 +128,11 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
newName: dynamicSecret.name,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"]),
|
||||
passwordRequirements: (dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
|
||||
getDefaultPasswordRequirements((dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres)
|
||||
passwordRequirements:
|
||||
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
|
||||
getDefaultPasswordRequirements(
|
||||
(dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -381,7 +388,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
/>
|
||||
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>Creation, Revocation & Renew Statements (optional)</AccordionTrigger>
|
||||
<AccordionTrigger>
|
||||
Creation, Revocation & Renew Statements (optional)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Customize SQL statements for managing database user lifecycle
|
||||
@@ -472,10 +481,10 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -483,17 +492,20 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(() => {
|
||||
const total = Object.values(watch("inputs.passwordRequirements.required") || {}).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const total = Object.values(
|
||||
watch("inputs.passwordRequirements.required") || {}
|
||||
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const length = watch("inputs.passwordRequirements.length") || 0;
|
||||
const isError = total > length;
|
||||
return (
|
||||
<span className={isError ? "text-red-500" : ""}>
|
||||
Total required characters: {total} {isError ? `(exceeds length of ${length})` : ""}
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
@@ -510,9 +522,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -530,9 +542,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -550,9 +562,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -570,9 +582,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
+6
-7
@@ -1,17 +1,17 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import { faClose, faFolder, faPencilSquare, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
||||
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
||||
|
||||
import { FolderForm } from "../ActionBar/FolderForm";
|
||||
|
||||
@@ -118,16 +118,15 @@ export const FolderListView = ({
|
||||
onClick={() => handleFolderClick(name)}
|
||||
>
|
||||
{name}
|
||||
{
|
||||
description &&
|
||||
{description && (
|
||||
<Tooltip
|
||||
position="right"
|
||||
className="flex items-center space-x-4 max-w-lg py-4 whitespace-pre-wrap"
|
||||
className="flex max-w-lg items-center space-x-4 whitespace-pre-wrap py-4"
|
||||
content={description}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400 ml-1" />
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||
<ProjectPermissionCan
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export const PitDrawer = ({
|
||||
<div>
|
||||
{(() => {
|
||||
const distance = formatDistance(new Date(createdAt), new Date());
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1) + " ago";
|
||||
return `${distance.charAt(0).toUpperCase() + distance.slice(1)} ago`;
|
||||
})()}
|
||||
</div>
|
||||
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === id)}</div>
|
||||
|
||||
+1
-1
@@ -58,10 +58,10 @@ import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
import { CreateReminderForm } from "./CreateReminderForm";
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user