chore(ui): migrate 5243 changes (#12269)

This commit is contained in:
Pedro Martín
2026-07-31 13:10:17 +02:00
committed by GitHub
parent bd9fa88d2a
commit b56df840fc
15 changed files with 398 additions and 7 deletions
+39
View File
@@ -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");
});
});
+9 -1
View File
@@ -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",
+5
View File
@@ -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");
});
});
+5
View File
@@ -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",
);
});
});
+13 -1
View File
@@ -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}&nbsp;
<CustomLink size="md" href={href} target="_self">
<CustomLink size="md" href={targetHref} target="_self">
{linkText}
</CustomLink>
</p>
+10 -2
View File
@@ -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}
/>
+11 -3
View File
@@ -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({
+44
View File
@@ -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({});
});
});
});
+37
View File
@@ -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 {};
}
};
+66
View File
@@ -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();
});
});
+65
View File
@@ -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);
}
};
+3
View File
@@ -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);
}