mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ac1a1a23 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -22,11 +22,20 @@ export interface ReverseProxy {
|
||||
meta?: ReverseProxyMeta;
|
||||
}
|
||||
|
||||
export const CrowdSecMode = {
|
||||
OFF: "off",
|
||||
ENFORCE: "enforce",
|
||||
OBSERVE: "observe",
|
||||
} as const;
|
||||
|
||||
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
crowdsec_mode?: CrowdSecMode;
|
||||
}
|
||||
|
||||
export interface ReverseProxyMeta {
|
||||
@@ -102,6 +111,7 @@ export interface ReverseProxyDomain {
|
||||
target_cluster?: string;
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
supports_crowdsec?: boolean;
|
||||
}
|
||||
|
||||
export enum ReverseProxyDomainType {
|
||||
@@ -149,6 +159,7 @@ export interface ReverseProxyEvent {
|
||||
bytes_upload: number;
|
||||
bytes_download: number;
|
||||
protocol?: EventProtocol;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function isL4Event(event: ReverseProxyEvent): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import Button from "@components/Button";
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { CountrySelector } from "@/components/ui/CountrySelector";
|
||||
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
|
||||
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
|
||||
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
|
||||
|
||||
type AccessAction = "allow" | "block";
|
||||
type AccessRuleType = "country" | "ip" | "cidr";
|
||||
@@ -93,30 +94,41 @@ function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
|
||||
}
|
||||
}
|
||||
|
||||
function pushCidrRules(
|
||||
rules: AccessRule[],
|
||||
values: string[] | undefined,
|
||||
action: AccessAction,
|
||||
) {
|
||||
values?.forEach((v) => {
|
||||
const isIp = v.includes(":") ? v.endsWith("/128") : v.endsWith("/32");
|
||||
rules.push({
|
||||
id: nextId(),
|
||||
action,
|
||||
type: isIp ? "ip" : "cidr",
|
||||
value: isIp ? v.replace(/\/(32|128)$/, "") : v,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function restrictionsToRules(
|
||||
restrictions: AccessRestrictions | undefined,
|
||||
): AccessRule[] {
|
||||
if (!restrictions) return [];
|
||||
const rules: AccessRule[] = [];
|
||||
restrictions.allowed_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
|
||||
);
|
||||
pushCidrRules(rules, restrictions.blocked_cidrs, "block");
|
||||
restrictions.blocked_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
|
||||
);
|
||||
restrictions.allowed_cidrs?.forEach((v) => {
|
||||
const isIp = v.endsWith("/32");
|
||||
rules.push({ id: nextId(), action: "allow", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
|
||||
});
|
||||
restrictions.blocked_cidrs?.forEach((v) => {
|
||||
const isIp = v.endsWith("/32");
|
||||
rules.push({ id: nextId(), action: "block", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
|
||||
});
|
||||
pushCidrRules(rules, restrictions.allowed_cidrs, "allow");
|
||||
restrictions.allowed_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
|
||||
);
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rulesToRestrictions(
|
||||
rules: AccessRule[],
|
||||
crowdsecMode?: CrowdSecMode,
|
||||
): AccessRestrictions | undefined {
|
||||
const allowed_countries: string[] = [];
|
||||
const blocked_countries: string[] = [];
|
||||
@@ -129,17 +141,23 @@ function rulesToRestrictions(
|
||||
if (rule.action === "allow") allowed_countries.push(rule.value);
|
||||
else blocked_countries.push(rule.value);
|
||||
} else {
|
||||
const value = rule.type === "ip" && !rule.value.includes("/") ? `${rule.value}/32` : rule.value;
|
||||
const suffix = rule.value.includes(":") ? "/128" : "/32";
|
||||
const value =
|
||||
rule.type === "ip" && !rule.value.includes("/")
|
||||
? `${rule.value}${suffix}`
|
||||
: rule.value;
|
||||
if (rule.action === "allow") allowed_cidrs.push(value);
|
||||
else blocked_cidrs.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
|
||||
const hasAny =
|
||||
allowed_countries.length > 0 ||
|
||||
blocked_countries.length > 0 ||
|
||||
allowed_cidrs.length > 0 ||
|
||||
blocked_cidrs.length > 0;
|
||||
blocked_cidrs.length > 0 ||
|
||||
hasCrowdSec;
|
||||
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
@@ -148,6 +166,7 @@ function rulesToRestrictions(
|
||||
...(blocked_countries.length > 0 && { blocked_countries }),
|
||||
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
||||
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
||||
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +174,7 @@ type Props = {
|
||||
value: AccessRestrictions | undefined;
|
||||
onChange: (value: AccessRestrictions | undefined) => void;
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
supportsCrowdSec?: boolean;
|
||||
};
|
||||
|
||||
function validateRule(rule: AccessRule): string {
|
||||
@@ -172,13 +192,22 @@ function validateRule(rule: AccessRule): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationChange }: Props) => {
|
||||
export const ReverseProxyAccessControlRules = ({
|
||||
value,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
supportsCrowdSec,
|
||||
}: Props) => {
|
||||
const [rules, dispatch] = useReducer(
|
||||
rulesReducer,
|
||||
value,
|
||||
restrictionsToRules,
|
||||
);
|
||||
|
||||
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
|
||||
value?.crowdsec_mode ?? CrowdSecMode.OFF,
|
||||
);
|
||||
|
||||
const errors = useMemo(
|
||||
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
|
||||
[rules],
|
||||
@@ -196,8 +225,14 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
|
||||
onValidationChangeRef.current = onValidationChange;
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current(rulesToRestrictions(rules));
|
||||
}, [rules]);
|
||||
if (!supportsCrowdSec) {
|
||||
setCrowdsecMode(CrowdSecMode.OFF);
|
||||
}
|
||||
}, [supportsCrowdSec]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
|
||||
}, [rules, crowdsecMode]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(hasErrors);
|
||||
@@ -205,6 +240,13 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
|
||||
|
||||
return (
|
||||
<div className={"flex-col flex"}>
|
||||
{supportsCrowdSec && (
|
||||
<ReverseProxyCrowdSecIPReputation
|
||||
value={crowdsecMode}
|
||||
onChange={setCrowdsecMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Access Control Rules</Label>
|
||||
<HelpText>
|
||||
@@ -301,15 +343,17 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="dotted"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "add" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Rule
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="dotted"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "add" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import { CrowdSecMode } from "@/interfaces/ReverseProxy";
|
||||
import Image from "next/image";
|
||||
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
|
||||
|
||||
type Props = {
|
||||
value: CrowdSecMode;
|
||||
onChange: (value: CrowdSecMode) => void;
|
||||
};
|
||||
|
||||
type CrowdSecOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const CROWDSEC_OPTIONS: Record<CrowdSecMode, CrowdSecOption> = {
|
||||
[CrowdSecMode.OFF]: {
|
||||
label: "Disabled",
|
||||
icon: <PowerOffIcon size={14} />,
|
||||
},
|
||||
[CrowdSecMode.ENFORCE]: {
|
||||
label: "Enforce",
|
||||
description:
|
||||
"Blocked IPs are denied immediately. If the bouncer is not yet synced, connections are denied (fail-closed).",
|
||||
icon: <ShieldCheckIcon size={14} />,
|
||||
},
|
||||
[CrowdSecMode.OBSERVE]: {
|
||||
label: "Observe",
|
||||
description:
|
||||
"Blocked IPs are logged but not denied. Use this to evaluate CrowdSec before enforcing.",
|
||||
icon: <EyeIcon size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const ReverseProxyCrowdSecIPReputation = ({
|
||||
value,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const selected = CROWDSEC_OPTIONS[value];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 justify-between mb-6">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={
|
||||
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={CrowdSecIconImage}
|
||||
alt={"CrowdSec"}
|
||||
className={"rounded-[4px]"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>CrowdSec IP Reputation</Label>
|
||||
<HelpText>
|
||||
Detect malicious IPs with CrowdSec.{" "}
|
||||
<b className={"text-white"}>Enforce</b> to block them or{" "}
|
||||
<b className={"text-white"}>Observe</b> to only log without
|
||||
blocking.
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={value} onValueChange={(v) => onChange(v as CrowdSecMode)}>
|
||||
<SelectTrigger className="w-[260px]">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
{selected.icon}
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CROWDSEC_OPTIONS).map(([mode, config]) => (
|
||||
<SelectItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
extra={
|
||||
config.description ? (
|
||||
<HelpTooltip
|
||||
triggerClassName="ml-[0.01rem]"
|
||||
align="center"
|
||||
side="right"
|
||||
content={<>{config.description}</>}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className="whitespace-nowrap">{config.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -590,6 +590,7 @@ export default function ReverseProxyModal({
|
||||
value={accessRestrictions}
|
||||
onChange={setAccessRestrictions}
|
||||
onValidationChange={setAccessControlHasErrors}
|
||||
supportsCrowdSec={selectedDomain?.supports_crowdsec}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Mail,
|
||||
Network,
|
||||
RectangleEllipsis,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -69,6 +71,26 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
||||
icon: <GlobeOff size={12} />,
|
||||
label: "Geo Unavailable",
|
||||
};
|
||||
case "crowdsec_ban":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "CrowdSec Ban",
|
||||
};
|
||||
case "crowdsec_captcha":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "CrowdSec Captcha",
|
||||
};
|
||||
case "crowdsec_throttle":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "CrowdSec Throttle",
|
||||
};
|
||||
case "crowdsec_unavailable":
|
||||
return {
|
||||
icon: <ShieldOff size={12} />,
|
||||
label: "CrowdSec Unavailable",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
|
||||
@@ -1,11 +1,61 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { ListItem } from "@components/ListItem";
|
||||
import { Info, ShieldAlert } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
|
||||
const VERDICT_LABELS: Record<string, string> = {
|
||||
crowdsec_ban: "Ban",
|
||||
crowdsec_captcha: "Captcha",
|
||||
crowdsec_throttle: "Throttle",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
event: ReverseProxyEvent;
|
||||
};
|
||||
|
||||
export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
|
||||
const metadata = event.metadata;
|
||||
const verdict = metadata?.crowdsec_verdict;
|
||||
|
||||
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) {
|
||||
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
|
||||
const metaEntries = Object.entries(metadata!).filter(
|
||||
([k]) => k !== "crowdsec_verdict",
|
||||
);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
side="top"
|
||||
interactive
|
||||
delayDuration={250}
|
||||
skipDelayDuration={100}
|
||||
disabled={metaEntries.length === 0}
|
||||
contentClassName="p-0"
|
||||
content={
|
||||
<div className="text-xs flex flex-col">
|
||||
{metaEntries.map(([key, val]) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
icon={<Info size={14} />}
|
||||
label={key.replaceAll("_", " ")}
|
||||
value={<span className="text-nb-gray-200">{val}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<Badge variant="gray" className="gap-1.5">
|
||||
<ShieldAlert size={12} className="text-yellow-500" />
|
||||
CrowdSec Observe: {verdictLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-nb-gray-300 text-[0.82rem] py-2 text-left">
|
||||
{event.reason || "-"}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LucideIcon,
|
||||
NetworkIcon,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
WorkflowIcon,
|
||||
@@ -19,7 +20,7 @@ import { useMemo } from "react";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type RuleEntry = {
|
||||
key: string;
|
||||
@@ -37,17 +38,27 @@ export default function ReverseProxyAccessControlCell({
|
||||
reverseProxy,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { openModal } = useReverseProxies();
|
||||
const { openModal, domains } = useReverseProxies();
|
||||
const { countries } = useCountries();
|
||||
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const restrictions = reverseProxy.access_restrictions;
|
||||
|
||||
const supportsCrowdSec = domains?.find(
|
||||
(d) => d.domain === reverseProxy.proxy_cluster,
|
||||
)?.supports_crowdsec;
|
||||
|
||||
const hasCrowdSec =
|
||||
supportsCrowdSec &&
|
||||
restrictions?.crowdsec_mode != null &&
|
||||
restrictions.crowdsec_mode !== CrowdSecMode.OFF;
|
||||
|
||||
const ruleCount =
|
||||
(restrictions?.allowed_cidrs?.length ?? 0) +
|
||||
(restrictions?.blocked_cidrs?.length ?? 0) +
|
||||
(restrictions?.allowed_countries?.length ?? 0) +
|
||||
(restrictions?.blocked_countries?.length ?? 0);
|
||||
(restrictions?.blocked_countries?.length ?? 0) +
|
||||
(hasCrowdSec ? 1 : 0);
|
||||
|
||||
const rulesBadge =
|
||||
ruleCount > 0 ? (
|
||||
@@ -92,21 +103,23 @@ export default function ReverseProxyAccessControlCell({
|
||||
});
|
||||
}
|
||||
|
||||
const isHostCidr = (c: string) =>
|
||||
c.includes(":") ? c.endsWith("/128") : c.endsWith("/32");
|
||||
const allowedIps =
|
||||
restrictions?.allowed_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
|
||||
restrictions?.allowed_cidrs?.filter(isHostCidr) ?? [];
|
||||
const allowedCidrs =
|
||||
restrictions?.allowed_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
|
||||
restrictions?.allowed_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
|
||||
const blockedIps =
|
||||
restrictions?.blocked_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
|
||||
restrictions?.blocked_cidrs?.filter(isHostCidr) ?? [];
|
||||
const blockedCidrs =
|
||||
restrictions?.blocked_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
|
||||
restrictions?.blocked_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
|
||||
|
||||
if (allowedIps.length) {
|
||||
entries.push({
|
||||
key: "allowed-ips",
|
||||
label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs",
|
||||
Icon: WorkflowIcon,
|
||||
value: allowedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
|
||||
value: allowedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +137,7 @@ export default function ReverseProxyAccessControlCell({
|
||||
key: "blocked-ips",
|
||||
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
|
||||
Icon: WorkflowIcon,
|
||||
value: blockedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
|
||||
value: blockedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
@@ -139,8 +152,20 @@ export default function ReverseProxyAccessControlCell({
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCrowdSec) {
|
||||
entries.push({
|
||||
key: "crowdsec",
|
||||
label: "CrowdSec",
|
||||
Icon: ShieldAlert,
|
||||
value:
|
||||
restrictions?.crowdsec_mode === CrowdSecMode.ENFORCE
|
||||
? "Enforce"
|
||||
: "Observe",
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [restrictions, countries]);
|
||||
}, [restrictions, countries, hasCrowdSec]);
|
||||
|
||||
const showRulesHover = ruleGroups.length > 0;
|
||||
|
||||
|
||||
Reference in new issue
Block a user