mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Update github form ui add scope and init org api
This commit is contained in:
@@ -343,6 +343,38 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:integrationAuthId/github/orgs",
|
||||
method: "GET",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
integrationAuthId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
orgs: z.object({ name: z.string(), orgId: z.string() }).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const orgs = await server.services.integrationAuth.getGithubOrgs({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.integrationAuthId
|
||||
});
|
||||
if (!orgs) throw new Error("No organization found.");
|
||||
|
||||
return { orgs: orgs || [] };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { orgs: [] };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:integrationAuthId/qovery/orgs",
|
||||
method: "GET",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
TIntegrationAuthAppsDTO,
|
||||
TIntegrationAuthBitbucketWorkspaceDTO,
|
||||
TIntegrationAuthChecklyGroupsDTO,
|
||||
TIntegrationAuthGithubOrgsDTO,
|
||||
TIntegrationAuthNorthflankSecretGroupDTO,
|
||||
TIntegrationAuthQoveryEnvironmentsDTO,
|
||||
TIntegrationAuthQoveryOrgsDTO,
|
||||
@@ -381,6 +383,33 @@ export const integrationAuthServiceFactory = ({
|
||||
return [];
|
||||
};
|
||||
|
||||
const getGithubOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthGithubOrgsDTO) => {
|
||||
const integrationAuth = await integrationAuthDAL.findById(id);
|
||||
if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
integrationAuth.projectId,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
|
||||
const botKey = await projectBotService.getBotKey(integrationAuth.projectId);
|
||||
const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey);
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: accessToken
|
||||
});
|
||||
|
||||
const { data } = await octokit.request("GET /organizations", {
|
||||
headers: {
|
||||
"X-GitHub-Api-Version": "2022-11-28"
|
||||
}
|
||||
});
|
||||
if (!data) return [];
|
||||
return data.map(({ login: name, id: orgId }) => ({ name, orgId }));
|
||||
};
|
||||
|
||||
const getQoveryOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthQoveryOrgsDTO) => {
|
||||
const integrationAuth = await integrationAuthDAL.findById(id);
|
||||
if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" });
|
||||
@@ -911,6 +940,7 @@ export const integrationAuthServiceFactory = ({
|
||||
getIntegrationApps,
|
||||
getVercelBranches,
|
||||
getApps,
|
||||
getGithubOrgs,
|
||||
getChecklyGroups,
|
||||
getQoveryApps,
|
||||
getQoveryEnvs,
|
||||
|
||||
@@ -44,6 +44,10 @@ export type TIntegrationAuthChecklyGroupsDTO = {
|
||||
accountId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TIntegrationAuthGithubOrgsDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TIntegrationAuthQoveryOrgsDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
@@ -1869,6 +1869,18 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/v1/integration-auth/{integrationAuthId}/github/orgs:
|
||||
get:
|
||||
description: ''
|
||||
parameters:
|
||||
- name: integrationAuthId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/v1/integration-auth/{integrationAuthId}/qovery/orgs:
|
||||
get:
|
||||
description: ''
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
useGetIntegrationAuthBitBucketWorkspaces,
|
||||
useGetIntegrationAuthById,
|
||||
useGetIntegrationAuthChecklyGroups,
|
||||
useGetIntegrationAuthGithubOrgs,
|
||||
useGetIntegrationAuthNorthflankSecretGroups,
|
||||
useGetIntegrationAuthRailwayEnvironments,
|
||||
useGetIntegrationAuthRailwayServices,
|
||||
|
||||
@@ -38,6 +38,8 @@ const integrationAuthKeys = {
|
||||
integrationAuthId: string;
|
||||
accountId: string;
|
||||
}) => [{ integrationAuthId, accountId }, "integrationAuthChecklyGroups"] as const,
|
||||
getIntegrationAuthGithubOrgs: (integrationAuthId: string) =>
|
||||
[{ integrationAuthId }, "integrationAuthGithubOrgs"] as const,
|
||||
getIntegrationAuthQoveryOrgs: (integrationAuthId: string) =>
|
||||
[{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const,
|
||||
getIntegrationAuthQoveryProjects: ({
|
||||
@@ -174,6 +176,16 @@ const fetchIntegrationAuthVercelBranches = async ({
|
||||
return branches;
|
||||
};
|
||||
|
||||
const fetchIntegrationAuthGithubOrgs = async (integrationAuthId: string) => {
|
||||
const {
|
||||
data: { orgs }
|
||||
} = await apiRequest.get<{ orgs: Org[] }>(
|
||||
`/api/v1/integration-auth/${integrationAuthId}/github/orgs`
|
||||
);
|
||||
|
||||
return orgs;
|
||||
};
|
||||
|
||||
const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => {
|
||||
const {
|
||||
data: { orgs }
|
||||
@@ -465,6 +477,14 @@ export const useGetIntegrationAuthChecklyGroups = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetIntegrationAuthGithubOrgs = (integrationAuthId: string) => {
|
||||
return useQuery({
|
||||
queryKey: integrationAuthKeys.getIntegrationAuthGithubOrgs(integrationAuthId),
|
||||
queryFn: () => fetchIntegrationAuthGithubOrgs(integrationAuthId),
|
||||
enabled: true
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => {
|
||||
return useQuery({
|
||||
queryKey: integrationAuthKeys.getIntegrationAuthQoveryOrgs(integrationAuthId),
|
||||
|
||||
@@ -20,8 +20,6 @@ import queryString from "query-string";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { useCreateIntegration } from "@app/hooks/api";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -38,26 +36,50 @@ import {
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs
|
||||
} from "../../../components/v2";
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
useCreateIntegration,
|
||||
useGetIntegrationAuthApps,
|
||||
useGetIntegrationAuthById
|
||||
} from "../../../hooks/api/integrationAuth";
|
||||
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
|
||||
useGetIntegrationAuthById,
|
||||
useGetIntegrationAuthGithubOrgs,
|
||||
useGetWorkspaceById
|
||||
} from "@app/hooks/api";
|
||||
|
||||
enum TabSections {
|
||||
Connection = "connection",
|
||||
Options = "options"
|
||||
}
|
||||
|
||||
const targetEnv = [
|
||||
"github-repo",
|
||||
"github-org",
|
||||
"github-env"
|
||||
] as const;
|
||||
type TargetEnv = typeof targetEnv[number];
|
||||
|
||||
|
||||
const schema = yup.object({
|
||||
selectedSourceEnvironment: yup.string().trim().required("Project Environment is required"),
|
||||
secretPath: yup.string().trim().required("Secrets Path is required"),
|
||||
targetAppIds: yup
|
||||
secretSuffix: yup.string().trim().optional(),
|
||||
|
||||
scope: yup.mixed<TargetEnv>().oneOf(targetEnv.slice()).required(),
|
||||
repoIds: yup
|
||||
.array(yup.string().required())
|
||||
.min(1, "Select atleast one repo") // .min() not working showing error for empty array
|
||||
.required("Select atleast one repo"),
|
||||
secretSuffix: yup.string().trim().optional()
|
||||
.optional(),
|
||||
repoId: yup
|
||||
.string()
|
||||
.optional(),
|
||||
|
||||
envId: yup
|
||||
.string()
|
||||
.optional(),
|
||||
|
||||
orgId: yup
|
||||
.string()
|
||||
.optional(),
|
||||
|
||||
});
|
||||
|
||||
type FormData = yup.InferType<typeof schema>;
|
||||
@@ -75,19 +97,25 @@ export default function GitHubCreateIntegrationPage() {
|
||||
useGetIntegrationAuthApps({
|
||||
integrationAuthId: (integrationAuthId as string) ?? ""
|
||||
});
|
||||
|
||||
const { data: integrationAuthOrgs } =
|
||||
useGetIntegrationAuthGithubOrgs(integrationAuthId as string);
|
||||
|
||||
const { control, handleSubmit, watch, setValue } = useForm<FormData>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
selectedSourceEnvironment: "",
|
||||
secretPath: "/",
|
||||
targetAppIds: [],
|
||||
secretSuffix: ""
|
||||
repoIds: [],
|
||||
secretSuffix: "",
|
||||
scope: "github-repo"
|
||||
}
|
||||
});
|
||||
|
||||
const targetAppIds = watch("targetAppIds");
|
||||
const scope = watch("scope");
|
||||
|
||||
const repoIds = watch("repoIds");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,9 +127,9 @@ export default function GitHubCreateIntegrationPage() {
|
||||
useEffect(() => {
|
||||
if (integrationAuthApps) {
|
||||
if (integrationAuthApps.length > 0) {
|
||||
setValue("targetAppIds", [String(integrationAuthApps[0].appId)]);
|
||||
setValue("repoIds", [String(integrationAuthApps[0].appId)]);
|
||||
} else {
|
||||
setValue("targetAppIds", ["none"]);
|
||||
setValue("repoIds", ["none"]);
|
||||
}
|
||||
}
|
||||
}, [integrationAuthApps]);
|
||||
@@ -113,7 +141,7 @@ export default function GitHubCreateIntegrationPage() {
|
||||
if (!integrationAuth?.id) return;
|
||||
|
||||
const targetApps = integrationAuthApps?.filter((integrationAuthApp) =>
|
||||
data.targetAppIds.includes(String(integrationAuthApp.appId))
|
||||
data.repoIds?.includes(String(integrationAuthApp.appId))
|
||||
);
|
||||
|
||||
if (!targetApps) return;
|
||||
@@ -217,7 +245,7 @@ export default function GitHubCreateIntegrationPage() {
|
||||
{workspace?.environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`github-environment-${sourceEnvironment.slug}`}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
@@ -226,7 +254,6 @@ export default function GitHubCreateIntegrationPage() {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
@@ -243,81 +270,192 @@ export default function GitHubCreateIntegrationPage() {
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="targetAppIds"
|
||||
render={({ field: { onChange }}) => (
|
||||
name="scope"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="GitHub Repo"
|
||||
// BUG: yup.min() not working as expected needs to be fixed
|
||||
errorText="Atleast one repo is required"
|
||||
isError={targetAppIds?.length === 0}
|
||||
label="Scope"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{integrationAuthApps.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{targetAppIds.length === 1
|
||||
? integrationAuthApps?.find(
|
||||
(integrationAuthApp) =>
|
||||
targetAppIds[0] === String(integrationAuthApp.appId)
|
||||
)?.name
|
||||
: `${targetAppIds.length} repositories selected`}
|
||||
<FontAwesomeIcon icon={faAngleDown} className="text-xs" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No repositories found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80 overflow-y-scroll"
|
||||
>
|
||||
{integrationAuthApps.length > 0 ? (
|
||||
integrationAuthApps.map((integrationAuthApp) => {
|
||||
const isSelected = targetAppIds.includes(
|
||||
String(integrationAuthApp.appId)
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (targetAppIds.includes(String(integrationAuthApp.appId))) {
|
||||
onChange(
|
||||
targetAppIds.filter(
|
||||
(appId) => appId !== String(integrationAuthApp.appId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onChange([...targetAppIds, String(integrationAuthApp.appId)]);
|
||||
}
|
||||
}}
|
||||
key={integrationAuthApp.appId}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{integrationAuthApp.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
<SelectItem value="github-repo">Github Repositories</SelectItem>
|
||||
<SelectItem value="github-org">Github Organization</SelectItem>
|
||||
<SelectItem value="github-env">Github Environment</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{scope === "github-repo" && repoIds && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="repoIds"
|
||||
render={({ field: { onChange }}) => (
|
||||
<FormControl
|
||||
label="GitHub Repo"
|
||||
// BUG: yup.min() not working as expected needs to be fixed
|
||||
errorText="Atleast one repo is required"
|
||||
isError={repoIds?.length === 0}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{integrationAuthApps.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{repoIds.length === 1
|
||||
? integrationAuthApps?.find(
|
||||
(integrationAuthApp) =>
|
||||
repoIds[0] === String(integrationAuthApp.appId)
|
||||
)?.name
|
||||
: `${repoIds.length} repositories selected`}
|
||||
<FontAwesomeIcon icon={faAngleDown} className="text-xs" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No repositories found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80 overflow-y-scroll"
|
||||
>
|
||||
{integrationAuthApps.length > 0 ? (
|
||||
integrationAuthApps.map((integrationAuthApp) => {
|
||||
const isSelected = repoIds.includes(
|
||||
String(integrationAuthApp.appId)
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (repoIds.includes(String(integrationAuthApp.appId))) {
|
||||
onChange(
|
||||
repoIds.filter(
|
||||
(appId) => appId !== String(integrationAuthApp.appId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onChange([...repoIds, String(integrationAuthApp.appId)]);
|
||||
}
|
||||
}}
|
||||
key={`repos-id-${integrationAuthApp.appId}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{integrationAuthApp.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
)}
|
||||
{scope === "github-org" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="orgId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Organization"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{integrationAuthOrgs && integrationAuthOrgs.map(({name, orgId}) => (
|
||||
<SelectItem
|
||||
key={`github-organization-${orgId}`}
|
||||
value={orgId}
|
||||
>
|
||||
{name}
|
||||
</SelectItem>
|
||||
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{scope === "github-env" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="repoId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Repositories"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{integrationAuthApps.length > 0 ? (
|
||||
integrationAuthApps.map((integrationAuthApp) => {
|
||||
return (
|
||||
<SelectItem
|
||||
value={integrationAuthApp.appId as string}
|
||||
key={`repos-id-${integrationAuthApp.appId}`}
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{integrationAuthApp.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)
|
||||
}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{scope === "github-env" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="envId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
<SelectItem value="github-repo">Select Environment</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Options}>
|
||||
@@ -355,7 +493,7 @@ export default function GitHubCreateIntegrationPage() {
|
||||
variant="outline_bg"
|
||||
className="mb-6"
|
||||
isLoading={isLoading}
|
||||
isDisabled={integrationAuthApps.length === 0 || targetAppIds.length === 0}
|
||||
isDisabled={integrationAuthApps.length === 0 || repoIds?.length === 0}
|
||||
>
|
||||
Create Integration
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user