mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-07 19:30:07 -05:00
feat: security signup ui (#7088)
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
|
||||
const ZCreatedUser = ZUser.pick({
|
||||
@@ -44,6 +45,9 @@ const ZCreateUserAction = z.object({
|
||||
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
||||
"CAPTCHA verification required"
|
||||
),
|
||||
isFormbricksCloud: z.boolean(),
|
||||
subscribeToSecurityUpdates: z.boolean().optional(),
|
||||
subscribeToProductUpdates: z.boolean().optional(),
|
||||
});
|
||||
|
||||
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||
@@ -191,6 +195,13 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
parsedInput.inviteToken,
|
||||
parsedInput.emailVerificationDisabled
|
||||
);
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: user.email,
|
||||
isFormbricksCloud: parsedInput.isFormbricksCloud,
|
||||
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
||||
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
@@ -48,6 +49,7 @@ interface SignupFormProps {
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -69,6 +71,7 @@ export const SignupForm = ({
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
isFormbricksCloud,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -76,6 +79,8 @@ export const SignupForm = ({
|
||||
const inviteToken = searchParams?.get("inviteToken");
|
||||
const router = useRouter();
|
||||
const [turnstileToken, setTurnstileToken] = useState<string>();
|
||||
const [subscribeToSecurityUpdates, setSubscribeToSecurityUpdates] = useState(false);
|
||||
const [subscribeToProductUpdates, setSubscribeToProductUpdates] = useState(false);
|
||||
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
@@ -110,6 +115,9 @@ export const SignupForm = ({
|
||||
inviteToken: inviteToken ?? "",
|
||||
emailVerificationDisabled,
|
||||
turnstileToken,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
@@ -239,6 +247,43 @@ export const SignupForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogin &&
|
||||
(isFormbricksCloud ? (
|
||||
<label
|
||||
htmlFor="product-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="product-updates"
|
||||
checked={subscribeToProductUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToProductUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.product_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.product_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="security-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="security-updates"
|
||||
checked={subscribeToSecurityUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToSecurityUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.security_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.security_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -76,6 +77,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ const CONFIG = {
|
||||
env.ENVIRONMENT === "staging"
|
||||
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||
: "https://ee.formbricks.com/api/licenses/check",
|
||||
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { subscribeToMailingList, subscribeUserToMailingList } from "./mailing-subscription";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
describe("subscribeToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should successfully subscribe to security mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Successfully subscribed to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should successfully subscribe to product-updates mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "product-updates",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when API returns non-ok response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe: 400" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ status: 400, error: "Bad Request" },
|
||||
"Failed to subscribe to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when fetch throws an error", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe to mailing list" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Error subscribing to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return timeout error when request times out", async () => {
|
||||
const abortError = new Error("Aborted");
|
||||
abortError.name = "AbortError";
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(abortError);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Request timed out" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Mailing subscription request timed out"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeUserToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should subscribe to product-updates when isFormbricksCloud is true and subscribeToProductUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is true but subscribeToProductUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should subscribe to security when isFormbricksCloud is false and subscribeToSecurityUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
subscribeToProductUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is false but subscribeToSecurityUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: false,
|
||||
subscribeToProductUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not subscribe when both subscription flags are undefined", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should prioritize product-updates for cloud users even if security is also true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
// Should only call product-updates endpoint for cloud users
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export type TMailingListId = "security" | "product-updates";
|
||||
|
||||
const MAILING_LIST_ENDPOINTS: Record<TMailingListId, string> = {
|
||||
security: "https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
"product-updates": "https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
} as const;
|
||||
|
||||
const EE_SERVER_TIMEOUT_MS = 5000;
|
||||
|
||||
interface TSubscribeToMailingListParams {
|
||||
email: TUserEmail;
|
||||
listId: TMailingListId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a user to a mailing list via the EE server
|
||||
* @param email - The user's email address
|
||||
* @param listId - The mailing list ID ("security" or "product-updates")
|
||||
*/
|
||||
export const subscribeToMailingList = async ({
|
||||
email,
|
||||
listId,
|
||||
}: TSubscribeToMailingListParams): Promise<{ success: boolean; error?: string }> => {
|
||||
validateInputs([email, ZUserEmail.toLowerCase()]);
|
||||
|
||||
const endpoint = MAILING_LIST_ENDPOINTS[listId];
|
||||
if (!endpoint) {
|
||||
logger.error({ listId }, "Invalid mailing list ID");
|
||||
return { success: false, error: "Invalid mailing list ID" };
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), EE_SERVER_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(
|
||||
{ status: response.status, error: errorText },
|
||||
`Failed to subscribe to ${listId} mailing list`
|
||||
);
|
||||
return { success: false, error: `Failed to subscribe: ${response.status}` };
|
||||
}
|
||||
|
||||
logger.info({ listId }, `Successfully subscribed to ${listId} mailing list`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.error({ listId }, "Mailing subscription request timed out");
|
||||
return { success: false, error: "Request timed out" };
|
||||
}
|
||||
|
||||
logger.error(error, `Error subscribing to ${listId} mailing list`);
|
||||
return { success: false, error: "Failed to subscribe to mailing list" };
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeUserToMailingList = async ({
|
||||
email,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
}: {
|
||||
email: TUserEmail;
|
||||
isFormbricksCloud: boolean;
|
||||
subscribeToSecurityUpdates?: boolean;
|
||||
subscribeToProductUpdates?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (isFormbricksCloud && subscribeToProductUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "product-updates" });
|
||||
} else if (!isFormbricksCloud && subscribeToSecurityUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "security" });
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -57,6 +58,7 @@ export const SignupPage = async () => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user