mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 16:40:30 +00:00
feature: secret scanning pt 3
This commit is contained in:
@@ -128,6 +128,11 @@ export enum ProjectPermissionSecretScanningFindingActions {
|
||||
Update = "update-findings"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretScanningConfigActions {
|
||||
Read = "read-configs",
|
||||
Update = "update-configs"
|
||||
}
|
||||
|
||||
export enum PermissionConditionOperators {
|
||||
$IN = "$in",
|
||||
$ALL = "$all",
|
||||
@@ -216,7 +221,8 @@ export enum ProjectPermissionSub {
|
||||
SecretSyncs = "secret-syncs",
|
||||
Kmip = "kmip",
|
||||
SecretScanningDataSources = "secret-scanning-data-sources",
|
||||
SecretScanningFindings = "secret-scanning-findings"
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
SecretScanningConfigs = "secret-scanning-configs"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -345,6 +351,7 @@ export type ProjectPermissionSet =
|
||||
ProjectPermissionSecretScanningDataSourceActions,
|
||||
ProjectPermissionSub.SecretScanningDataSources
|
||||
]
|
||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings];
|
||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
|
||||
|
||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||
|
||||
@@ -201,7 +201,9 @@ export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.SECRET_SCANNING_RESOURCE_LIST]: "List Secret Scanning Resources",
|
||||
[EventType.SECRET_SCANNING_SCAN_LIST]: "List Secret Scanning Scans",
|
||||
[EventType.SECRET_SCANNING_FINDING_LIST]: "List Secret Scanning Findings",
|
||||
[EventType.SECRET_SCANNING_FINDING_UPDATE]: "Update Secret Scanning Finding Status"
|
||||
[EventType.SECRET_SCANNING_FINDING_UPDATE]: "Update Secret Scanning Finding Status",
|
||||
[EventType.SECRET_SCANNING_CONFIG_GET]: "Get Secret Scanning Configuration",
|
||||
[EventType.SECRET_SCANNING_CONFIG_UPDATE]: "Update Secret Scanning Configuration"
|
||||
};
|
||||
|
||||
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
|
||||
|
||||
@@ -195,5 +195,7 @@ export enum EventType {
|
||||
SECRET_SCANNING_RESOURCE_LIST = "secret-scanning-resource-list",
|
||||
SECRET_SCANNING_SCAN_LIST = "secret-scanning-scan-list",
|
||||
SECRET_SCANNING_FINDING_LIST = "secret-scanning-finding-list",
|
||||
SECRET_SCANNING_FINDING_UPDATE = "secret-scanning-finding-update"
|
||||
SECRET_SCANNING_FINDING_UPDATE = "secret-scanning-finding-update",
|
||||
SECRET_SCANNING_CONFIG_GET = "secret-scanning-config-get",
|
||||
SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { secretScanningV2Keys } from "./queries";
|
||||
import {
|
||||
TCreateSecretScanningDataSourceDTO,
|
||||
TDeleteSecretScanningDataSourceDTO,
|
||||
TGetSecretScanningConfigResponse,
|
||||
TSecretScanningConfig,
|
||||
TSecretScanningDataSourceResponse,
|
||||
TSecretScanningFindingResponse,
|
||||
TTriggerSecretScanningDataSourceDTO,
|
||||
@@ -128,7 +130,31 @@ export const useUpdateSecretScanningFinding = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretScanningV2Keys.listFindings(projectId)
|
||||
});
|
||||
// TODO: data source queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretScanningV2Keys.findingCount(projectId)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretScanningV2Keys.dataSource()
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSecretScanningConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ projectId, ...params }: TSecretScanningConfig) => {
|
||||
const { data } = await apiRequest.patch<TGetSecretScanningConfigResponse>(
|
||||
`/api/v2/secret-scanning/configs/${projectId}`,
|
||||
params
|
||||
);
|
||||
|
||||
return data.config;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretScanningV2Keys.configByProjectId(projectId)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import {
|
||||
TGetSecretScanningConfigResponse,
|
||||
TGetSecretScanningDataSource,
|
||||
TGetSecretScanningUnresolvedFindingsResponse,
|
||||
TListSecretScanningDataSourceOptions,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
TListSecretScanningFindingsResponse,
|
||||
TListSecretScanningResourcesResponse,
|
||||
TListSecretScanningScansResponse,
|
||||
TSecretScanningConfig,
|
||||
TSecretScanningDataSource,
|
||||
TSecretScanningDataSourceOption,
|
||||
TSecretScanningDataSourceResponse,
|
||||
@@ -42,7 +44,9 @@ export const secretScanningV2Keys = {
|
||||
finding: () => [...secretScanningV2Keys.all, "finding"] as const,
|
||||
findingCount: (projectId: string) =>
|
||||
[...secretScanningV2Keys.finding(), "count", projectId] as const,
|
||||
listFindings: (projectId: string) => [...secretScanningV2Keys.finding(), "list", projectId]
|
||||
listFindings: (projectId: string) => [...secretScanningV2Keys.finding(), "list", projectId],
|
||||
configByProjectId: (projectId: string) =>
|
||||
[...secretScanningV2Keys.all, "config", projectId] as const
|
||||
};
|
||||
|
||||
export const useSecretScanningDataSourceOptions = (
|
||||
@@ -216,3 +220,28 @@ export const useListSecretScanningFindings = (
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetSecretScanningConfig = (
|
||||
projectId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TSecretScanningConfig,
|
||||
unknown,
|
||||
TSecretScanningConfig,
|
||||
ReturnType<typeof secretScanningV2Keys.configByProjectId>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: secretScanningV2Keys.configByProjectId(projectId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TGetSecretScanningConfigResponse>(
|
||||
`/api/v2/secret-scanning/configs/${projectId}`
|
||||
);
|
||||
|
||||
return data.config;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,6 +115,15 @@ export type TGetSecretScanningUnresolvedFindingsResponse = {
|
||||
unresolvedFindings: number;
|
||||
};
|
||||
|
||||
export type TSecretScanningConfig = {
|
||||
projectId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type TGetSecretScanningConfigResponse = {
|
||||
config: TSecretScanningConfig;
|
||||
};
|
||||
|
||||
export type TSecretScanningFinding = {
|
||||
id: string;
|
||||
dataSourceName: string;
|
||||
|
||||
+50
-3
@@ -20,6 +20,7 @@ import {
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretRotationActions,
|
||||
ProjectPermissionSecretScanningConfigActions,
|
||||
ProjectPermissionSecretScanningDataSourceActions,
|
||||
ProjectPermissionSecretScanningFindingActions,
|
||||
ProjectPermissionSecretSyncActions,
|
||||
@@ -107,6 +108,11 @@ const SecretScanningFindingPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretScanningFindingActions.Update]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretScanningConfigPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretScanningConfigActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretScanningConfigActions.Update]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const KmipPolicyActionSchema = z.object({
|
||||
[ProjectPermissionKmipActions.ReadClients]: z.boolean().optional(),
|
||||
[ProjectPermissionKmipActions.CreateClients]: z.boolean().optional(),
|
||||
@@ -295,7 +301,9 @@ export const projectRoleFormSchema = z.object({
|
||||
[ProjectPermissionSub.SecretScanningDataSources]:
|
||||
SecretScanningDataSourcePolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretScanningFindings]:
|
||||
SecretScanningFindingPolicyActionSchema.array().default([])
|
||||
SecretScanningFindingPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretScanningConfigs]:
|
||||
SecretScanningConfigPolicyActionSchema.array().default([])
|
||||
})
|
||||
.partial()
|
||||
.optional()
|
||||
@@ -777,6 +785,18 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
formVal[subject]![0][ProjectPermissionSecretScanningFindingActions.Update] = true;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretScanningConfigs) {
|
||||
const canRead = action.includes(ProjectPermissionSecretScanningConfigActions.Read);
|
||||
const canUpdate = action.includes(ProjectPermissionSecretScanningConfigActions.Update);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (canRead) formVal[subject]![0][ProjectPermissionSecretScanningConfigActions.Read] = true;
|
||||
if (canUpdate)
|
||||
formVal[subject]![0][ProjectPermissionSecretScanningConfigActions.Update] = true;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SshHosts) {
|
||||
if (!formVal[subject]) formVal[subject] = [];
|
||||
|
||||
@@ -1363,6 +1383,19 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
value: ProjectPermissionSecretScanningFindingActions.Update
|
||||
}
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretScanningConfigs]: {
|
||||
title: "Secret Scanning Config",
|
||||
actions: [
|
||||
{
|
||||
label: "Read Config",
|
||||
value: ProjectPermissionSecretScanningConfigActions.Read
|
||||
},
|
||||
{
|
||||
label: "Update Config",
|
||||
value: ProjectPermissionSecretScanningConfigActions.Update
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1418,7 +1451,8 @@ const SshPermissionSubjects = (enabled = false) => ({
|
||||
|
||||
const SecretScanningSubject = (enabled = false) => ({
|
||||
[ProjectPermissionSub.SecretScanningDataSources]: enabled,
|
||||
[ProjectPermissionSub.SecretScanningFindings]: enabled
|
||||
[ProjectPermissionSub.SecretScanningFindings]: enabled,
|
||||
[ProjectPermissionSub.SecretScanningConfigs]: enabled
|
||||
});
|
||||
|
||||
// scott: this structure ensures we don't forget to add project permissions to their relevant project type
|
||||
@@ -1693,6 +1727,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretScanningFindings,
|
||||
actions: [ProjectPermissionSecretScanningFindingActions.Read]
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretScanningConfigs,
|
||||
actions: [ProjectPermissionSecretScanningConfigActions.Read]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1708,10 +1746,19 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretScanningFindings,
|
||||
actions: Object.values(ProjectPermissionSecretScanningFindingActions)
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretScanningConfigs,
|
||||
actions: [ProjectPermissionSecretScanningConfigActions.Read]
|
||||
}
|
||||
]
|
||||
},
|
||||
projectManagerTemplate()
|
||||
projectManagerTemplate([
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretScanningConfigs,
|
||||
actions: Object.values(ProjectPermissionSecretScanningConfigActions)
|
||||
}
|
||||
])
|
||||
],
|
||||
[ProjectType.SecretManager]: [
|
||||
{
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ export const SecretScanningScanRow = ({ scan }: Props) => {
|
||||
<span className="text-xs">
|
||||
{totalFindings}{" "}
|
||||
{unresolvedFindings
|
||||
? `Secret ${totalFindings > 1 ? "s" : ""} Detected`
|
||||
? `Secret${totalFindings > 1 ? "s" : ""} Detected`
|
||||
: "Leak Resolved"}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningConfigActions } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
|
||||
import { ProjectScanningConfigTab } from "./components/ProjectScanningConfigTab";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,26 +23,29 @@ export const SettingsPage = () => {
|
||||
<TabList>
|
||||
<Tab value="tab-project-general">General</Tab>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Project}
|
||||
I={ProjectPermissionSecretScanningConfigActions.Read}
|
||||
a={ProjectPermissionSub.SecretScanningConfigs}
|
||||
>
|
||||
{(isAllowed) =>
|
||||
isAllowed && <Tab value="tab-project-secret-scannings">Rule Settings</Tab>
|
||||
isAllowed && <Tab value="tab-project-scanning-config">Scanning Configuration</Tab>
|
||||
}
|
||||
</ProjectPermissionCan>
|
||||
</TabList>
|
||||
<TabPanel value="tab-project-general">
|
||||
<ProjectGeneralTab />
|
||||
</TabPanel>
|
||||
{/* <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningConfigActions.Read}
|
||||
a={ProjectPermissionSub.SecretScanningConfigs}
|
||||
>
|
||||
{(isAllowed) =>
|
||||
isAllowed && (
|
||||
<TabPanel value="tab-project-ssh">
|
||||
<ProjectSshTab />
|
||||
<TabPanel value="tab-project-scanning-config">
|
||||
<ProjectScanningConfigTab />
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
</ProjectPermissionCan> */}
|
||||
</ProjectPermissionCan>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { faBan } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { ContentLoader, EmptyState } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useGetSecretScanningConfig } from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningConfigForm } from "./SecretScanningConfigForm";
|
||||
|
||||
export const ProjectScanningConfigTab = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: config, isPending: isConfigPending } = useGetSecretScanningConfig(
|
||||
currentWorkspace.id
|
||||
);
|
||||
|
||||
if (isConfigPending) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-20">
|
||||
<EmptyState
|
||||
className="max-w-2xl rounded-md text-center"
|
||||
icon={faBan}
|
||||
title="Could not find Project Configuration"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SecretScanningConfigForm config={config} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, TextArea } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningConfigActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
TSecretScanningConfig,
|
||||
useUpdateSecretScanningConfig
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
type Props = {
|
||||
config: TSecretScanningConfig;
|
||||
};
|
||||
|
||||
const FormSchema = z.object({
|
||||
content: z.string().max(5000, "Configuration cannot exceed 5000 characters").optional()
|
||||
});
|
||||
|
||||
type FormType = z.infer<typeof FormSchema>;
|
||||
|
||||
export const SecretScanningConfigForm = ({ config }: Props) => {
|
||||
const updateConfig = useUpdateSecretScanningConfig();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isDirty }
|
||||
} = useForm<FormType>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
content: config.content ?? ""
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async ({ content }: FormType) => {
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
projectId: config.projectId,
|
||||
content: content || null
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Configuration successfully updated"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update Configuration"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-mineshaft-100">Project Configuration</h2>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://infisical.com/docs/documentation/platform/secret-scanning/overview#configuration"
|
||||
className="mt-[0.02rem]"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="leading-5 text-mineshaft-400">
|
||||
Configure rules and exceptions to customize scanning
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex w-full flex-col">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningConfigActions.Update}
|
||||
a={ProjectPermissionSub.SecretScanningConfigs}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<TextArea
|
||||
placeholder={
|
||||
"[extend]\n\nuseDefault = true\n\n# See docs for configuration guide"
|
||||
}
|
||||
{...field}
|
||||
rows={3}
|
||||
className="thin-scrollbar min-h-[36rem] w-full !resize-none !resize-y"
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="content"
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningConfigActions.Update}
|
||||
a={ProjectPermissionSub.SecretScanningConfigs}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
className="w-min"
|
||||
isLoading={updateConfig.isPending}
|
||||
isDisabled={updateConfig.isPending || !isAllowed || !isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ProjectScanningConfigTab } from "./ProjectScanningConfigTab";
|
||||
Reference in New Issue
Block a user