mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
chore(ui): migrate 5243 changes (#12269)
This commit is contained in:
@@ -63,4 +63,43 @@ describe("auth actions", () => {
|
||||
// Then
|
||||
expect(result).toEqual({ ...apiResponse, status: 400 });
|
||||
});
|
||||
|
||||
it("should forward attribution params when creating a user", async () => {
|
||||
// Given
|
||||
const apiResponse = {
|
||||
data: {
|
||||
type: "users",
|
||||
id: "019b1234-5678-7abc-9def-0123456789ab",
|
||||
},
|
||||
};
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(apiResponse), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await createNewUser(
|
||||
{
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
password: "TestPassword123!",
|
||||
confirmPassword: "TestPassword123!",
|
||||
company: "Prowler",
|
||||
termsAndConditions: undefined,
|
||||
isSamlMode: false,
|
||||
},
|
||||
{
|
||||
promo_code: "black-hat-2026",
|
||||
utm_source: "blackhat",
|
||||
},
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(apiResponse);
|
||||
const requestUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(requestUrl.searchParams.get("promo_code")).toBe("black-hat-2026");
|
||||
expect(requestUrl.searchParams.get("utm_source")).toBe("blackhat");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthError } from "next-auth";
|
||||
import { signIn, signOut } from "@/auth.config";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { addAuthEvent } from "@/lib/sentry-breadcrumbs";
|
||||
import type { UtmParams } from "@/lib/utm";
|
||||
import type { SignInFormData, SignUpFormData } from "@/types";
|
||||
|
||||
export async function authenticate(
|
||||
@@ -47,13 +48,20 @@ export async function authenticate(
|
||||
}
|
||||
}
|
||||
|
||||
export const createNewUser = async (formData: SignUpFormData) => {
|
||||
export const createNewUser = async (
|
||||
formData: SignUpFormData,
|
||||
attribution: UtmParams = {},
|
||||
) => {
|
||||
const url = new URL(`${apiBaseUrl}/users`);
|
||||
|
||||
if (formData.invitationToken) {
|
||||
url.searchParams.append("invitation_token", formData.invitationToken);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(attribution)) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
|
||||
const bodyData = {
|
||||
data: {
|
||||
type: "users",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import {
|
||||
getAttributionParamsFromCallbackPath,
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
@@ -15,12 +16,16 @@ export async function GET(req: Request) {
|
||||
const code = searchParams.get("code");
|
||||
const callbackPath = getSafeCallbackPath(searchParams);
|
||||
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
const attribution = getAttributionParamsFromCallbackPath(callbackPath);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("code", code || "");
|
||||
if (invitationToken) {
|
||||
params.append("invitation_token", invitationToken);
|
||||
}
|
||||
for (const [key, value] of Object.entries(attribution)) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, signInMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
signInMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/auth.config", () => ({
|
||||
signIn: signInMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
baseUrl: "https://app.example.com",
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("Google OAuth callback route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
signInMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should forward callback attribution to the token exchange", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(
|
||||
Response.json({
|
||||
data: {
|
||||
attributes: {
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const state = encodeURIComponent(
|
||||
"/?promo_code=black-hat-2026&utm_source=blackhat",
|
||||
);
|
||||
const request = new Request(
|
||||
`https://app.example.com/api/auth/callback/google?code=oauth-code&state=${state}`,
|
||||
);
|
||||
|
||||
// When
|
||||
await GET(request);
|
||||
|
||||
// Then
|
||||
const body = new URLSearchParams(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.get("code")).toBe("oauth-code");
|
||||
expect(body.get("promo_code")).toBe("black-hat-2026");
|
||||
expect(body.get("utm_source")).toBe("blackhat");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import {
|
||||
getAttributionParamsFromCallbackPath,
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
@@ -15,12 +16,16 @@ export async function GET(req: Request) {
|
||||
const code = searchParams.get("code");
|
||||
const callbackPath = getSafeCallbackPath(searchParams);
|
||||
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
const attribution = getAttributionParamsFromCallbackPath(callbackPath);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("code", code || "");
|
||||
if (invitationToken) {
|
||||
params.append("invitation_token", invitationToken);
|
||||
}
|
||||
for (const [key, value] of Object.entries(attribution)) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Sign-up campaign attribution preserves `promo_code` and `utm_*` params across auth redirects, sign-in/sign-up links, Google/GitHub OAuth callbacks, and `POST /users`
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AuthFooterLink } from "./auth-footer-link";
|
||||
|
||||
const navigationState = vi.hoisted(() => ({
|
||||
searchParams: new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => navigationState.searchParams,
|
||||
}));
|
||||
|
||||
describe("AuthFooterLink", () => {
|
||||
it("should preserve attribution params in the target href", () => {
|
||||
// Given
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"promo_code=black-hat-2026&utm_source=blackhat&foo=bar",
|
||||
);
|
||||
|
||||
// When
|
||||
render(
|
||||
<AuthFooterLink
|
||||
text="Need to create an account?"
|
||||
linkText="Sign up"
|
||||
href="/sign-up"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("link", { name: "Sign up" })).toHaveAttribute(
|
||||
"href",
|
||||
"/sign-up?promo_code=black-hat-2026&utm_source=blackhat",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
import { appendAttributionToCallbackPath } from "@/lib/auth-callback-url";
|
||||
import { extractUtmParams } from "@/lib/utm";
|
||||
|
||||
interface AuthFooterLinkProps {
|
||||
text: string;
|
||||
@@ -11,10 +17,16 @@ export const AuthFooterLink = ({
|
||||
linkText,
|
||||
href,
|
||||
}: AuthFooterLinkProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const targetHref = appendAttributionToCallbackPath(
|
||||
href,
|
||||
extractUtmParams(searchParams),
|
||||
);
|
||||
|
||||
return (
|
||||
<p className="text-center text-sm">
|
||||
{text}
|
||||
<CustomLink size="md" href={href} target="_self">
|
||||
<CustomLink size="md" href={targetHref} target="_self">
|
||||
{linkText}
|
||||
</CustomLink>
|
||||
</p>
|
||||
|
||||
@@ -21,8 +21,12 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { CustomInput } from "@/components/shadcn/custom";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
|
||||
import {
|
||||
appendAttributionToCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
import { stripPasswordManagerHighlight } from "@/lib/password-manager";
|
||||
import { extractUtmParams } from "@/lib/utm";
|
||||
import { SignInFormData, signInSchema } from "@/types";
|
||||
|
||||
export const SignInForm = ({
|
||||
@@ -40,6 +44,10 @@ export const SignInForm = ({
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = getSafeCallbackPath(searchParams, "callbackUrl");
|
||||
const socialCallbackUrl = appendAttributionToCallbackPath(
|
||||
callbackUrl,
|
||||
extractUtmParams(searchParams),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const samlError = searchParams.get("sso_saml_failed");
|
||||
@@ -198,7 +206,7 @@ export const SignInForm = ({
|
||||
<SocialButtons
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
callbackUrl={callbackUrl}
|
||||
callbackUrl={socialCallbackUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
isGithubOAuthEnabled={isGithubOAuthEnabled}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { createNewUser } from "@/actions/auth";
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
FormField,
|
||||
FormMessage,
|
||||
} from "@/components/shadcn/form";
|
||||
import { appendAttributionToCallbackPath } from "@/lib/auth-callback-url";
|
||||
import { stripPasswordManagerHighlight } from "@/lib/password-manager";
|
||||
import { extractUtmParams } from "@/lib/utm";
|
||||
import { ApiError, SignUpFormData, signUpSchema } from "@/types";
|
||||
|
||||
const AUTH_ERROR_PATHS = {
|
||||
@@ -54,10 +56,16 @@ export const SignUpForm = ({
|
||||
isGithubOAuthEnabled?: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = invitationToken
|
||||
const utmParams = extractUtmParams(searchParams);
|
||||
const baseCallbackUrl = invitationToken
|
||||
? `/invitation/accept?invitation_token=${encodeURIComponent(invitationToken)}`
|
||||
: "/";
|
||||
const callbackUrl = appendAttributionToCallbackPath(
|
||||
baseCallbackUrl,
|
||||
utmParams,
|
||||
);
|
||||
|
||||
const form = useForm<SignUpFormData>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
@@ -89,7 +97,7 @@ export const SignUpForm = ({
|
||||
const isSocialAuthDisabled = Boolean(isCloudEnv && !termsAccepted);
|
||||
|
||||
const onSubmit = async (data: SignUpFormData) => {
|
||||
const newUser = await createNewUser(data);
|
||||
const newUser = await createNewUser(data, utmParams);
|
||||
|
||||
if (!newUser.errors) {
|
||||
toast({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendAttributionToCallbackPath,
|
||||
appendCallbackState,
|
||||
getAttributionParamsFromCallbackPath,
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
@@ -105,4 +107,46 @@ describe("auth callback URL helpers", () => {
|
||||
expect(result).toBe("test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when carrying campaign attribution", () => {
|
||||
it("should append attribution params to the callback path", () => {
|
||||
const result = appendAttributionToCallbackPath("/", {
|
||||
promo_code: "black-hat-2026",
|
||||
utm_source: "blackhat",
|
||||
});
|
||||
|
||||
expect(result).toBe("/?promo_code=black-hat-2026&utm_source=blackhat");
|
||||
});
|
||||
|
||||
it("should not override params already present in the path", () => {
|
||||
const result = appendAttributionToCallbackPath("/?promo_code=original", {
|
||||
promo_code: "other",
|
||||
});
|
||||
|
||||
expect(result).toBe("/?promo_code=original");
|
||||
});
|
||||
|
||||
it("should return the path untouched without attribution", () => {
|
||||
expect(appendAttributionToCallbackPath("/scans", {})).toBe("/scans");
|
||||
});
|
||||
|
||||
it("should read attribution params back from a callback path", () => {
|
||||
const result = getAttributionParamsFromCallbackPath(
|
||||
"/?promo_code=black-hat-2026&utm_source=blackhat&foo=bar",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
promo_code: "black-hat-2026",
|
||||
utm_source: "blackhat",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return no attribution for unsafe callback paths", () => {
|
||||
expect(
|
||||
getAttributionParamsFromCallbackPath(
|
||||
"https://attacker.example/?promo_code=x",
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { extractUtmParams, type UtmParams } from "@/lib/utm";
|
||||
|
||||
const DEFAULT_CALLBACK_PATH = "/";
|
||||
const INVITATION_TOKEN_PARAM = "invitation_token";
|
||||
// Origin used only to resolve relative paths; never part of the returned value.
|
||||
@@ -63,3 +65,38 @@ export const getInvitationTokenFromCallbackPath = (callbackPath: string) => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const appendAttributionToCallbackPath = (
|
||||
callbackPath: string,
|
||||
attribution: UtmParams,
|
||||
): string => {
|
||||
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
|
||||
if (Object.keys(attribution).length === 0) {
|
||||
return safeCallbackPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(safeCallbackPath, INTERNAL_ORIGIN);
|
||||
for (const [key, value] of Object.entries(attribution)) {
|
||||
if (!url.searchParams.has(key)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch (_error) {
|
||||
return safeCallbackPath;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAttributionParamsFromCallbackPath = (
|
||||
callbackPath: string,
|
||||
): UtmParams => {
|
||||
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
|
||||
|
||||
try {
|
||||
const url = new URL(safeCallbackPath, INTERNAL_ORIGIN);
|
||||
return extractUtmParams(url.searchParams);
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { copyAttributionParams, extractUtmParams } from "./utm";
|
||||
|
||||
describe("extractUtmParams", () => {
|
||||
it("captures every utm_* param when utm_source is present", () => {
|
||||
// Given
|
||||
const params = new URLSearchParams(
|
||||
"utm_source=blackhat&utm_medium=conference&utm_content=badge&foo=bar",
|
||||
);
|
||||
|
||||
// When / Then
|
||||
expect(extractUtmParams(params)).toEqual({
|
||||
utm_source: "blackhat",
|
||||
utm_medium: "conference",
|
||||
utm_content: "badge",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty for utm params without attribution source", () => {
|
||||
// Given / When / Then
|
||||
expect(extractUtmParams({ utm_content: "alerts" })).toEqual({});
|
||||
});
|
||||
|
||||
it("captures promo_code on its own as valid attribution", () => {
|
||||
// Given
|
||||
const params = new URLSearchParams(
|
||||
"promo_code=95b2c481-f9d5-4fc2-bc34-0b542e25f00b",
|
||||
);
|
||||
|
||||
// When / Then
|
||||
expect(extractUtmParams(params)).toEqual({
|
||||
promo_code: "95b2c481-f9d5-4fc2-bc34-0b542e25f00b",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets promo_code carry sourceless utm params", () => {
|
||||
// Given
|
||||
const params = new URLSearchParams("utm_content=badge&promo_code=abc");
|
||||
|
||||
// When / Then
|
||||
expect(extractUtmParams(params)).toEqual({
|
||||
utm_content: "badge",
|
||||
promo_code: "abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyAttributionParams", () => {
|
||||
it("copies attribution params and drops unrelated params", () => {
|
||||
// Given
|
||||
const source = new URLSearchParams(
|
||||
"promo_code=black-hat-2026&utm_source=blackhat&foo=bar",
|
||||
);
|
||||
const target = new URLSearchParams("callbackUrl=/");
|
||||
|
||||
// When
|
||||
copyAttributionParams(source, target);
|
||||
|
||||
// Then
|
||||
expect(target.get("callbackUrl")).toBe("/");
|
||||
expect(target.get("promo_code")).toBe("black-hat-2026");
|
||||
expect(target.get("utm_source")).toBe("blackhat");
|
||||
expect(target.get("foo")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
export const UTM_PARAM_PREFIX = "utm_";
|
||||
const UTM_SOURCE_KEY = "utm_source";
|
||||
export const PROMO_CODE_KEY = "promo_code";
|
||||
|
||||
export type UtmParams = Record<string, string>;
|
||||
|
||||
type SearchParamSource = {
|
||||
forEach(callback: (value: string, key: string) => void): void;
|
||||
};
|
||||
|
||||
type RecordParamSource = Record<string, string | string[] | undefined>;
|
||||
|
||||
type UtmSource = SearchParamSource | RecordParamSource;
|
||||
|
||||
const isSearchParamSource = (source: UtmSource): source is SearchParamSource =>
|
||||
"forEach" in source && typeof source.forEach === "function";
|
||||
|
||||
const toEntries = (source: UtmSource): [string, string][] => {
|
||||
if (isSearchParamSource(source)) {
|
||||
const entries: [string, string][] = [];
|
||||
source.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
return Object.entries(source).flatMap(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
return [[key, value]];
|
||||
}
|
||||
if (Array.isArray(value) && typeof value[0] === "string") {
|
||||
return [[key, value[0]]];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
export const extractUtmParams = (source: UtmSource): UtmParams => {
|
||||
const utm: UtmParams = {};
|
||||
let hasAttribution = false;
|
||||
|
||||
for (const [key, value] of toEntries(source)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === PROMO_CODE_KEY) {
|
||||
utm[PROMO_CODE_KEY] = value;
|
||||
hasAttribution = true;
|
||||
} else if (lowerKey.startsWith(UTM_PARAM_PREFIX)) {
|
||||
utm[key] = value;
|
||||
if (lowerKey === UTM_SOURCE_KEY) {
|
||||
hasAttribution = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasAttribution ? utm : {};
|
||||
};
|
||||
|
||||
export const copyAttributionParams = (
|
||||
from: URLSearchParams,
|
||||
to: URLSearchParams,
|
||||
): void => {
|
||||
for (const [key, value] of Object.entries(extractUtmParams(from))) {
|
||||
to.set(key, value);
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/integrations";
|
||||
import { readEnv } from "@/lib/runtime-env";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { copyAttributionParams } from "@/lib/utm";
|
||||
|
||||
const publicRoutes = [
|
||||
"/sign-in",
|
||||
@@ -63,12 +64,14 @@ export default auth((req: NextAuthRequest) => {
|
||||
const signInUrl = new URL("/sign-in", req.url);
|
||||
signInUrl.searchParams.set("error", sessionError);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
|
||||
copyAttributionParams(req.nextUrl.searchParams, signInUrl.searchParams);
|
||||
return redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (!user && !isPublicRoute(pathname)) {
|
||||
const signInUrl = new URL("/sign-in", req.url);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
|
||||
copyAttributionParams(req.nextUrl.searchParams, signInUrl.searchParams);
|
||||
return redirect(signInUrl);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user