Compare commits

..

20 Commits

Author SHA1 Message Date
Piyush Gupta 93c72df4d9 fix: changes 2025-06-30 19:04:50 +05:30
Piyush Gupta 49560ccba8 fix: reset password email enumeration 2025-06-30 18:30:07 +05:30
Piyush Gupta 3f98283d4d fix: review changes 2025-06-30 17:10:30 +05:30
Piyush Gupta 7b64422a3f Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-06-30 17:09:32 +05:30
Piyush Gupta a7ee1f189f fix: docker build validation workflow 2025-05-13 17:04:41 +05:30
Piyush Gupta 46a590311b Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-13 17:03:50 +05:30
Piyush Gupta 0faeffb624 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 17:10:02 +05:30
Piyush Gupta d9727a336a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 13:50:29 +05:30
Piyush Gupta 330e0db668 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 10:58:53 +05:30
Piyush Gupta f5b7f73199 test: enhance EditProfileDetailsForm tests with password reset functionality 2025-05-09 16:02:39 +05:30
Piyush Gupta c02f070307 fix: functionality 2025-05-09 15:41:00 +05:30
Piyush Gupta bc489e050a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-09 11:41:59 +05:30
Kunal Garg 3062059ed5 feat: added description and logout flow 2025-04-19 13:45:22 +05:30
Johannes f27ede6b2c fix button 2025-04-15 08:48:31 +07:00
Piyush Gupta e460ff5100 fix: error handling 2025-04-08 19:02:41 +05:30
Piyush Gupta 4699c0014b fix: reset password 2025-04-08 18:45:24 +05:30
Piyush Gupta 52f69be05d Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-04-08 18:37:31 +05:30
Kunal Garg 619c0983a4 fix: input type fixed 2025-04-04 12:09:17 +05:30
Kunal Garg 964fb8d4f4 fix: html tag type 2025-04-03 15:44:52 +05:30
Kunal Garg 5391c60bba feat: reset password in accounts page 2025-04-03 15:29:58 +05:30
41 changed files with 149 additions and 312 deletions
+41 -14
View File
@@ -25,9 +25,21 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# Copy the secrets handling script # BuildKit secret handling without hardcoded fallback values
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh # This approach relies entirely on secrets passed from GitHub Actions
RUN chmod +x /tmp/read-secrets.sh RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
@@ -50,9 +62,6 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install --ignore-scripts RUN pnpm install --ignore-scripts
# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the project using our secret reader script # Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \ RUN --mount=type=secret,id=database_url \
@@ -97,8 +106,20 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/dist ./packages/database/dist COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
@@ -121,14 +142,12 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
USER nextjs USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -139,4 +158,12 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
@@ -94,7 +94,6 @@ describe("LandingSidebar component", () => {
organizationId: "o1", organizationId: "o1",
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
}); });
@@ -130,7 +130,6 @@ export const LandingSidebar = ({
organizationId: organization.id, organizationId: organization.id,
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -221,6 +221,7 @@ describe("MainNavigation", () => {
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage // Set up localStorage spy on the mocked localStorage
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
render(<MainNavigation {...defaultProps} />); render(<MainNavigation {...defaultProps} />);
@@ -242,18 +243,23 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout"); const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton); await userEvent.click(logoutButton);
// Verify localStorage.removeItem is called with the correct key
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
organizationId: "org1", organizationId: "org1",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
await waitFor(() => { await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
}); });
// Clean up spy
removeItemSpy.mockRestore();
}); });
test("handles organization switching", async () => { test("handles organization switching", async () => {
@@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
@@ -390,13 +391,14 @@ export const MainNavigation = ({
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({ const route = await signOutWithAudit({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
organizationId: organization.id, organizationId: organization.id,
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}} }}
@@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
import { rateLimit } from "@/lib/utils/rate-limit"; import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { import {
@@ -162,21 +162,3 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
} }
) )
); );
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
}
)
);
@@ -1,9 +1,10 @@
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { resetPasswordAction, updateUserAction } from "../actions"; import { updateUserAction } from "../actions";
import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
const mockUser = { const mockUser = {
@@ -37,7 +38,6 @@ beforeEach(() => {
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateUserAction: vi.fn(), updateUserAction: vi.fn(),
resetPasswordAction: vi.fn(),
})); }));
vi.mock("@/modules/auth/forgot-password/actions", () => ({ vi.mock("@/modules/auth/forgot-password/actions", () => ({
@@ -144,7 +144,7 @@ describe("EditProfileDetailsForm", () => {
}); });
test("reset password button works", async () => { test("reset password button works", async () => {
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } }); vi.mocked(forgotPasswordAction).mockResolvedValue(undefined);
render( render(
<EditProfileDetailsForm <EditProfileDetailsForm
@@ -158,9 +158,8 @@ describe("EditProfileDetailsForm", () => {
await userEvent.click(resetButton); await userEvent.click(resetButton);
await waitFor(() => { await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled(); expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
}); });
await waitFor(() => { await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading"); expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
}); });
@@ -168,7 +167,7 @@ describe("EditProfileDetailsForm", () => {
test("reset password button handles error correctly", async () => { test("reset password button handles error correctly", async () => {
const errorMessage = "Reset failed"; const errorMessage = "Reset failed";
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage }); vi.mocked(forgotPasswordAction).mockRejectedValue(new Error(errorMessage));
render( render(
<EditProfileDetailsForm <EditProfileDetailsForm
@@ -182,16 +181,12 @@ describe("EditProfileDetailsForm", () => {
await userEvent.click(resetButton); await userEvent.click(resetButton);
await waitFor(() => { await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled(); expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
}); });
}); });
test("reset password button shows loading state", async () => { test("reset password button shows loading state", async () => {
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves vi.mocked(forgotPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
render( render(
<EditProfileDetailsForm <EditProfileDetailsForm
@@ -3,6 +3,7 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -23,7 +24,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { resetPasswordAction, updateUserAction } from "../actions"; import { updateUserAction } from "../actions";
// Schema & types // Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
@@ -97,7 +98,6 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/email-change-without-verification-success", redirectUrl: "/email-change-without-verification-success",
redirect: true, redirect: true,
callbackUrl: "/email-change-without-verification-success", callbackUrl: "/email-change-without-verification-success",
clearEnvironmentId: true,
}); });
return; return;
} }
@@ -130,23 +130,19 @@ export const EditProfileDetailsForm = ({
}; };
const handleResetPassword = async () => { const handleResetPassword = async () => {
if (!user.email) return;
setIsResettingPassword(true); setIsResettingPassword(true);
const result = await resetPasswordAction(); await forgotPasswordAction({ email: user.email });
if (result?.data) {
toast.success(t("auth.forgot-password.email-sent.heading"));
await signOutWithAudit({ toast.success(t("auth.forgot-password.email-sent.heading"));
reason: "password_reset", await signOutWithAudit({
redirectUrl: "/auth/login", reason: "password_reset",
redirect: true, redirectUrl: "/auth/login",
callbackUrl: "/auth/login", redirect: true,
clearEnvironmentId: true, callbackUrl: "/auth/login",
}); });
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(t(errorMessage));
}
setIsResettingPassword(false); setIsResettingPassword(false);
}; };
+1 -1
View File
@@ -1231,7 +1231,7 @@
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen", "copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren", "copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_success": "Umfrage erfolgreich kopiert!", "copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?", "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:", "2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
+1 -1
View File
@@ -1231,7 +1231,7 @@
"copy_survey_error": "Failed to copy survey", "copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard", "copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_success": "Survey copied successfully!", "copy_survey_success": "Survey copied successfully!",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?", "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:", "1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:", "2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
+1 -1
View File
@@ -1231,7 +1231,7 @@
"copy_survey_error": "Échec de la copie du sondage", "copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers", "copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_success": "Enquête copiée avec succès !", "copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?", "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :", "1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :", "2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
+1 -1
View File
@@ -1231,7 +1231,7 @@
"copy_survey_error": "Falha ao copiar pesquisa", "copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência", "copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_success": "Pesquisa copiada com sucesso!", "copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?", "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:", "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
+1 -1
View File
@@ -1231,7 +1231,7 @@
"copy_survey_error": "Falha ao copiar inquérito", "copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência", "copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_success": "Inquérito copiado com sucesso!", "copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?", "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:", "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
+1 -1
View File
@@ -1231,7 +1231,7 @@
"copy_survey_error": "無法複製問卷", "copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿", "copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_success": "問卷已成功複製!", "copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?", "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:", "1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:", "2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
@@ -1,3 +1,4 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
@@ -99,6 +100,8 @@ describe("DeleteAccountModal", () => {
/> />
); );
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
const input = screen.getByTestId("deleteAccountConfirmation"); const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } }); fireEvent.change(input, { target: { value: mockUser.email } });
@@ -110,8 +113,8 @@ describe("DeleteAccountModal", () => {
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion", reason: "account_deletion",
redirect: false, // Updated to match new implementation redirect: false, // Updated to match new implementation
clearEnvironmentId: true,
}); });
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith("/auth/login"); expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
expect(mockSetOpen).toHaveBeenCalledWith(false); expect(mockSetOpen).toHaveBeenCalledWith(false);
}); });
@@ -148,13 +151,15 @@ describe("DeleteAccountModal", () => {
const form = screen.getByTestId("deleteAccountForm"); const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form); fireEvent.submit(form);
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
await waitFor(() => { await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled(); expect(deleteUserAction).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion", reason: "account_deletion",
redirect: false, // Updated to match new implementation redirect: false, // Updated to match new implementation
clearEnvironmentId: true,
}); });
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith( expect(window.location.replace).toHaveBeenCalledWith(
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2" "https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
); );
@@ -1,5 +1,6 @@
"use client"; "use client";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
@@ -38,11 +39,12 @@ export const DeleteAccountModal = ({
setDeleting(true); setDeleting(true);
await deleteUserAction(); await deleteUserAction();
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
// Sign out with account deletion reason (no automatic redirect) // Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({ await signOutWithAudit({
reason: "account_deletion", reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect redirect: false, // Prevent NextAuth automatic redirect
clearEnvironmentId: true,
}); });
// Manual redirect after signOut completes // Manual redirect after signOut completes
@@ -21,9 +21,9 @@ export const forgotPasswordAction = actionClient
const user = await getUserByEmail(parsedInput.email); const user = await getUserByEmail(parsedInput.email);
if (user && user.identityProvider === "email") { if (!user || user.identityProvider !== "email") {
await sendForgotPasswordEmail(user); return;
} }
return { success: true }; await sendForgotPasswordEmail(user);
}); });
@@ -1,4 +1,3 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { logSignOutAction } from "@/modules/auth/actions/sign-out"; import { logSignOutAction } from "@/modules/auth/actions/sign-out";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
@@ -15,7 +14,6 @@ interface UseSignOutOptions {
organizationId?: string; organizationId?: string;
redirect?: boolean; redirect?: boolean;
callbackUrl?: string; callbackUrl?: string;
clearEnvironmentId?: boolean;
} }
interface SessionUser { interface SessionUser {
@@ -44,10 +42,6 @@ export const useSignOut = (sessionUser?: SessionUser | null) => {
} }
} }
if (options?.clearEnvironmentId) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
}
// Call NextAuth signOut // Call NextAuth signOut
return await signOut({ return await signOut({
redirect: options?.redirect, redirect: options?.redirect,
@@ -50,7 +50,6 @@ export const ZAuditAction = z.enum([
"twoFactorRequired", "twoFactorRequired",
"emailVerificationAttempted", "emailVerificationAttempted",
"userSignedOut", "userSignedOut",
"passwordReset",
]); ]);
export const ZActor = z.enum(["user", "api", "system"]); export const ZActor = z.enum(["user", "api", "system"]);
export const ZAuditStatus = z.enum(["success", "failure"]); export const ZAuditStatus = z.enum(["success", "failure"]);
@@ -3,6 +3,14 @@ import { render } from "@testing-library/react";
import { type MockedFunction, beforeEach, describe, expect, test, vi } from "vitest"; import { type MockedFunction, beforeEach, describe, expect, test, vi } from "vitest";
import { ClientLogout } from "./index"; import { ClientLogout } from "./index";
// Mock the localStorage
const mockRemoveItem = vi.fn();
Object.defineProperty(window, "localStorage", {
value: {
removeItem: mockRemoveItem,
},
});
// Mock next-auth/react // Mock next-auth/react
const mockSignOut = vi.fn(); const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({ vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
@@ -29,7 +37,6 @@ describe("ClientLogout", () => {
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
@@ -43,10 +50,14 @@ describe("ClientLogout", () => {
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
test("removes environment ID from localStorage", () => {
render(<ClientLogout />);
expect(mockRemoveItem).toHaveBeenCalledWith("formbricks-environment-id");
});
test("renders null", () => { test("renders null", () => {
const { container } = render(<ClientLogout />); const { container } = render(<ClientLogout />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
@@ -1,5 +1,6 @@
"use client"; "use client";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -7,12 +8,12 @@ export const ClientLogout = () => {
const { signOut: signOutWithAudit } = useSignOut(); const { signOut: signOutWithAudit } = useSignOut();
useEffect(() => { useEffect(() => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
signOutWithAudit({ signOutWithAudit({
reason: "forced_logout", reason: "forced_logout",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
return null; return null;
@@ -21,27 +21,16 @@ export const OptionsSwitch = ({
const [highlightStyle, setHighlightStyle] = useState({}); const [highlightStyle, setHighlightStyle] = useState({});
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const updateHighlight = () => { if (containerRef.current) {
if (containerRef.current) { const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`);
const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`); if (activeElement) {
if (activeElement) { const { offsetLeft, offsetWidth } = activeElement as HTMLElement;
const { offsetLeft, offsetWidth } = activeElement as HTMLElement; setHighlightStyle({
setHighlightStyle({ left: `${offsetLeft}px`,
left: `${offsetLeft}px`, width: `${offsetWidth}px`,
width: `${offsetWidth}px`, });
});
} else {
// Hide highlight if no matching element found
setHighlightStyle({ opacity: 0 });
}
} }
}; }
// Initial call
updateHighlight();
// Listen to resize
window.addEventListener("resize", updateHighlight);
return () => window.removeEventListener("resize", updateHighlight);
}, [currentOption]); }, [currentOption]);
return ( return (
+1 -1
View File
@@ -14,7 +14,7 @@
"test": "dotenv -e ../../.env -- vitest run", "test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage", "test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
"generate-api-specs": "dotenv -e ../../.env tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml", "generate-api-specs": "dotenv -e ../../.env tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml",
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts", "merge-client-endpoints": "tsx ./scripts/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints" "generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
}, },
"dependencies": { "dependencies": {
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
set -eu
export NODE_ENV=production
if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then
echo "Starting cron jobs...";
supercronic -quiet /app/docker/cronjobs &
else
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0";
fi;
(cd packages/database && npm run db:migrate:deploy) &&
(cd packages/database && npm run db:create-saml-database:deploy) &&
exec node apps/web/server.js
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
set -eu
if [ -f "/run/secrets/database_url" ]; then
export DATABASE_URL=$(cat /run/secrets/database_url)
else
echo "DATABASE_URL secret not found. Build may fail if this is required."
fi
if [ -f "/run/secrets/encryption_key" ]; then
export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)
else
echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."
fi
exec "$@"
+3 -5
View File
@@ -83,18 +83,16 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Email follow-ups | ✅ | ✅ | | Email follow-ups | ✅ | ✅ |
| Multi-language UI | ✅ | ✅ | | Multi-language UI | ✅ | ✅ |
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ | | All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
| Domain Split Configuration | ✅ | ✅ |
| Hide "Powered by Formbricks" | ❌ | ✅ | | Hide "Powered by Formbricks" | ❌ | ✅ |
| Whitelabel email follow-ups | ❌ | ✅ | | Whitelabel email follow-ups | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ | | Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ | | Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ | | Multi-language surveys | ❌ | ✅ |
| Audit Logs | ❌ | ✅ |
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ | | OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
| SAML SSO | ❌ | ✅ | | SAML SSO | ❌ | ✅ |
| Spam protection (ReCaptchaV3) | ❌ | ✅ | | Spam protection (ReCaptchaV3) | ❌ | ✅ |
| Two-factor authentication | ❌ | ✅ | | Two-factor authentication | ❌ | ✅ |
| Custom 'Project' count | ❌ | ✅ | | Custom 'Project' count | ❌ | ✅ |
| White-glove onboarding | ❌ | ✅ | | White-glove onboarding | ❌ | ✅ |
| Support SLAs | ❌ | ✅ | | Support SLAs | ❌ | ✅ |
+9 -30
View File
@@ -3,41 +3,23 @@
"packageManager": "pnpm@9.15.9", "packageManager": "pnpm@9.15.9",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"main": "./dist/index.cjs", "main": "./src/index.ts",
"types": "./dist/index.d.ts",
"type": "module",
"files": [ "files": [
"dist", "src"
"schema.prisma",
"migration"
], ],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./types/*": {
"import": "./types/*.ts"
},
"./zod/*": {
"import": "./zod/*.ts"
}
},
"scripts": { "scripts": {
"clean": "rimraf .turbo node_modules dist", "clean": "rimraf .turbo node_modules",
"build": "pnpm generate && vite build", "db:migrate:deploy": "env DATABASE_URL=\"${MIGRATE_DATABASE_URL:-$DATABASE_URL}\" tsx ./src/scripts/apply-migrations.ts",
"dev": "vite build --watch", "db:migrate:dev": "dotenv -e ../../.env -- sh -c \"pnpm prisma generate && tsx ./src/scripts/apply-migrations.ts\"",
"db:migrate:deploy": "env DATABASE_URL=\"${MIGRATE_DATABASE_URL:-$DATABASE_URL}\" node ./dist/scripts/apply-migrations.js", "db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" tsx ./src/scripts/create-saml-database.ts",
"db:migrate:dev": "dotenv -e ../../.env -- sh -c \"pnpm prisma generate && node ./dist/scripts/apply-migrations.js\"", "db:create-saml-database:dev": "dotenv -e ../../.env -- tsx ./src/scripts/create-saml-database.ts",
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
"db:push": "prisma db push --accept-data-loss", "db:push": "prisma db push --accept-data-loss",
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev", "db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
"db:start": "pnpm db:setup", "db:start": "pnpm db:setup",
"format": "prisma format", "format": "prisma format",
"generate": "prisma generate", "generate": "prisma generate",
"lint": "eslint ./src --fix", "lint": "eslint ./src --fix",
"build": "pnpm generate",
"generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts", "generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts",
"create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts" "create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts"
}, },
@@ -52,11 +34,8 @@
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*", "@formbricks/eslint-config": "workspace:*",
"dotenv-cli": "8.0.0", "dotenv-cli": "8.0.0",
"glob": "11.0.2",
"prisma": "6.7.0", "prisma": "6.7.0",
"prisma-json-types-generator": "3.4.1", "prisma-json-types-generator": "3.4.1",
"ts-node": "10.9.2", "ts-node": "10.9.2"
"vite": "6.3.5",
"vite-plugin-dts": "4.5.3"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = (): PrismaClient => { const prismaClientSingleton = () => {
return new PrismaClient({ return new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } }, datasources: { db: { url: process.env.DATABASE_URL } },
...(process.env.DEBUG === "1" && { ...(process.env.DEBUG === "1" && {
@@ -15,6 +15,6 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined; prisma: PrismaClientSingleton | undefined;
}; };
export const prisma: PrismaClient = globalForPrisma.prisma ?? prismaClientSingleton(); export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
@@ -2,14 +2,10 @@ import { exec } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import readline from "node:readline"; import readline from "node:readline";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { applyMigrations } from "./migration-runner"; import { applyMigrations } from "./migration-runner";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execAsync = promisify(exec); const execAsync = promisify(exec);
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
@@ -2,12 +2,8 @@ import { createId } from "@paralleldrive/cuid2";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import readline from "node:readline"; import readline from "node:readline";
import { fileURLToPath } from "node:url";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
@@ -2,12 +2,9 @@ import { type Prisma, PrismaClient } from "@prisma/client";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execAsync = promisify(exec); const execAsync = promisify(exec);
export interface DataMigrationContext { export interface DataMigrationContext {
@@ -27,12 +24,7 @@ export interface MigrationScript {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
const MIGRATIONS_DIR = path.resolve(__dirname, "../../migration");
// Determine if we're running from built or source code
const isBuilt = __filename.split(path.sep).includes("dist");
const MIGRATIONS_DIR = isBuilt
? path.resolve(__dirname, "../migration") // From dist/scripts to dist/migration
: path.resolve(__dirname, "../../migration"); // From src/scripts to migration
const PRISMA_MIGRATIONS_DIR = path.resolve(__dirname, "../../migrations"); const PRISMA_MIGRATIONS_DIR = path.resolve(__dirname, "../../migrations");
const runMigrations = async (migrations: MigrationScript[]): Promise<void> => { const runMigrations = async (migrations: MigrationScript[]): Promise<void> => {
@@ -202,13 +194,11 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
const files = await fs.readdir(migrationPath); const files = await fs.readdir(migrationPath);
const hasSchemaMigration = files.includes("migration.sql"); const hasSchemaMigration = files.includes("migration.sql");
// Check for the appropriate data migration file extension based on build status const hasDataMigration = files.includes("migration.ts");
const dataMigrationFileName = isBuilt ? "migration.js" : "migration.ts";
const hasDataMigration = files.includes(dataMigrationFileName);
if (hasSchemaMigration && hasDataMigration) { if (hasSchemaMigration && hasDataMigration) {
throw new Error( throw new Error(
`Migration directory ${dirName} has both migration.sql and ${dataMigrationFileName}. This should not happen.` `Migration directory ${dirName} has both migration.sql and migration.ts. This should not happen.`
); );
} }
@@ -243,8 +233,7 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
} }
// It's a data migration, dynamically import and extract the scripts // It's a data migration, dynamically import and extract the scripts
// Use .js extension when running from built code, .ts when running from source const modulePath = path.join(migrationPath, "migration.ts");
const modulePath = path.join(migrationPath, dataMigrationFileName);
const mod = (await import(modulePath)) as Record<string, MigrationScript | undefined>; const mod = (await import(modulePath)) as Record<string, MigrationScript | undefined>;
// Check each export in the module for a DataMigrationScript (type: "data") // Check each export in the module for a DataMigrationScript (type: "data")
@@ -256,7 +245,7 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
} }
} else { } else {
logger.warn( logger.warn(
`Migration directory ${dirName} doesn't have migration.sql or ${dataMigrationFileName}. Skipping...` `Migration directory ${dirName} doesn't have migration.sql or data-migration.ts. Skipping...`
); );
} }
} }
@@ -282,11 +271,7 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
export async function applyMigrations(): Promise<void> { export async function applyMigrations(): Promise<void> {
try { try {
const allMigrations = await loadMigrations(); const allMigrations = await loadMigrations();
logger.info( logger.info(`Loaded ${allMigrations.length.toString()} migrations from ${MIGRATIONS_DIR}`);
`Loaded ${allMigrations.length.toString()} migrations from ${MIGRATIONS_DIR} (source: ${
isBuilt ? "dist" : "src"
})`
);
await runMigrations(allMigrations); await runMigrations(allMigrations);
} catch (error) { } catch (error) {
await prisma.$disconnect(); await prisma.$disconnect();
-80
View File
@@ -1,80 +0,0 @@
import { promises as fs } from "fs";
import { glob } from "glob";
import { dirname, resolve } from "path";
import { Plugin, UserConfig, defineConfig } from "vite";
import dts from "vite-plugin-dts";
const copySqlMigrationsPlugin: Plugin = {
name: "copy-sql-migrations",
async writeBundle() {
const sqlFiles = await glob("migration/**/migration.sql", { cwd: __dirname });
await Promise.all(
sqlFiles.map(async (file) => {
const srcPath = resolve(__dirname, file);
const destPath = resolve(__dirname, "dist", file);
await fs.mkdir(dirname(destPath), { recursive: true });
await fs.copyFile(srcPath, destPath);
})
);
},
};
export default defineConfig(async (): Promise<UserConfig> => {
const migrationTsFiles = await glob("migration/**/migration.ts", { cwd: __dirname });
const migrationEntries = migrationTsFiles.reduce((acc: Record<string, string>, file: string) => {
const dir = dirname(file);
const entryName = `${dir}/migration`;
acc[entryName] = resolve(__dirname, file);
return acc;
}, {});
return {
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "src/index.ts"),
"scripts/apply-migrations": resolve(__dirname, "src/scripts/apply-migrations.ts"),
"scripts/create-saml-database": resolve(__dirname, "src/scripts/create-saml-database.ts"),
"scripts/migration-runner": resolve(__dirname, "src/scripts/migration-runner.ts"),
...migrationEntries,
},
output: [
{
format: "esm",
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
},
{
format: "cjs",
entryFileNames: "[name].cjs",
chunkFileNames: "[name].cjs",
},
],
external: [
// External dependencies that should not be bundled
"@prisma/client",
"zod",
"zod-openapi",
"@paralleldrive/cuid2",
],
},
emptyOutDir: true,
ssr: true, // Server-side rendering mode for Node.js
},
plugins: [
dts({
rollupTypes: false,
include: ["src/**/*"],
exclude: ["src/**/*.test.ts", "src/**/*.spec.ts"],
insertTypesEntry: true,
}),
copySqlMigrationsPlugin,
],
};
});
+6 -6
View File
@@ -1,4 +1,4 @@
import { SurveyStatus, SurveyType } from "@prisma/client"; import { type Survey, SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi"; import { extendZodWithOpenApi } from "zod-openapi";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package // eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
@@ -92,10 +92,10 @@ const ZSurveyBase = z.object({
}), }),
questions: z.array(ZSurveyQuestion).openapi({ questions: z.array(ZSurveyQuestion).openapi({
description: "The questions of the survey", description: "The questions of the survey",
}), }) as z.ZodType<Survey["questions"]>,
endings: z.array(ZSurveyEnding).default([]).openapi({ endings: z.array(ZSurveyEnding).default([]).openapi({
description: "The endings of the survey", description: "The endings of the survey",
}), }) as z.ZodType<Survey["endings"]>,
thankYouCard: z thankYouCard: z
.object({ .object({
enabled: z.boolean(), enabled: z.boolean(),
@@ -115,7 +115,7 @@ const ZSurveyBase = z.object({
}), }),
variables: z.array(ZSurveyVariable).openapi({ variables: z.array(ZSurveyVariable).openapi({
description: "Survey variables", description: "Survey variables",
}), }) as z.ZodType<Survey["variables"]>,
displayOption: z.enum(["displayOnce", "displayMultiple", "displaySome", "respondMultiple"]).openapi({ displayOption: z.enum(["displayOnce", "displayMultiple", "displaySome", "respondMultiple"]).openapi({
description: "Display options for the survey", description: "Display options for the survey",
}), }),
@@ -219,10 +219,10 @@ const ZSurveyBase = z.object({
}), }),
displayPercentage: z.number().nullable().openapi({ displayPercentage: z.number().nullable().openapi({
description: "The display percentage of the survey", description: "The display percentage of the survey",
}), }) as z.ZodType<Survey["displayPercentage"]>,
}); });
export const ZSurvey = ZSurveyBase; export const ZSurvey = ZSurveyBase satisfies z.ZodType<Survey>;
export const ZSurveyWithoutQuestionType = ZSurveyBase.omit({ export const ZSurveyWithoutQuestionType = ZSurveyBase.omit({
questions: true, questions: true,
@@ -14,7 +14,7 @@ export function Headline({ headline, questionId, required = true, alignTextCente
<div <div
className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`} className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`}
dir="auto"> dir="auto">
<p>{headline}</p> {headline}
{!required && ( {!required && (
<span <span
className="fb-text-heading fb-mx-2 fb-self-start fb-text-sm fb-font-normal fb-leading-7 fb-opacity-60" className="fb-text-heading fb-mx-2 fb-self-start fb-text-sm fb-font-normal fb-leading-7 fb-opacity-60"
-3
View File
@@ -7,9 +7,6 @@ describe("i18n", () => {
test("should return empty string for undefined value", () => { test("should return empty string for undefined value", () => {
expect(getLocalizedValue(undefined, "en")).toBe(""); expect(getLocalizedValue(undefined, "en")).toBe("");
}); });
test("should return empty string for empty string", () => {
expect(getLocalizedValue({ default: "" }, "en")).toBe("");
});
test("should return empty string for non-i18n string", () => { test("should return empty string for non-i18n string", () => {
expect(getLocalizedValue("not an i18n string" as any, "en")).toBe(""); expect(getLocalizedValue("not an i18n string" as any, "en")).toBe("");
+1 -1
View File
@@ -10,7 +10,7 @@ export const getLocalizedValue = (value: TI18nString | undefined, languageId: st
return ""; return "";
} }
if (isI18nObject(value)) { if (isI18nObject(value)) {
if (typeof value[languageId] === "string") { if (value[languageId]) {
return value[languageId]; return value[languageId];
} }
return value.default; return value.default;
-9
View File
@@ -645,9 +645,6 @@ importers:
dotenv-cli: dotenv-cli:
specifier: 8.0.0 specifier: 8.0.0
version: 8.0.0 version: 8.0.0
glob:
specifier: 11.0.2
version: 11.0.2
prisma: prisma:
specifier: 6.7.0 specifier: 6.7.0
version: 6.7.0(typescript@5.8.3) version: 6.7.0(typescript@5.8.3)
@@ -657,12 +654,6 @@ importers:
ts-node: ts-node:
specifier: 10.9.2 specifier: 10.9.2
version: 10.9.2(@types/node@22.15.18)(typescript@5.8.3) version: 10.9.2(@types/node@22.15.18)(typescript@5.8.3)
vite:
specifier: 6.3.5
version: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0)
vite-plugin-dts:
specifier: 4.5.3
version: 4.5.3(@types/node@22.15.18)(rollup@4.40.2)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
packages/i18n-utils: packages/i18n-utils:
devDependencies: devDependencies:
+2 -2
View File
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8 sonar.sourceEncoding=UTF-8
# Coverage # Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/** sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/** sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
+4 -7
View File
@@ -6,7 +6,7 @@
"outputs": ["dist/**"] "outputs": ["dist/**"]
}, },
"@formbricks/database#lint": { "@formbricks/database#lint": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"] "dependsOn": ["@formbricks/logger#build"]
}, },
"@formbricks/database#setup": { "@formbricks/database#setup": {
"dependsOn": ["db:up"] "dependsOn": ["db:up"]
@@ -30,9 +30,6 @@
"dependsOn": ["@formbricks/database#db:setup"], "dependsOn": ["@formbricks/database#db:setup"],
"persistent": true "persistent": true
}, },
"@formbricks/js-core#lint": {
"dependsOn": ["@formbricks/database#build"]
},
"@formbricks/react-native#build": { "@formbricks/react-native#build": {
"dependsOn": ["^build", "@formbricks/database#build"], "dependsOn": ["^build", "@formbricks/database#build"],
"outputs": ["dist/**"] "outputs": ["dist/**"]
@@ -61,10 +58,10 @@
"persistent": true "persistent": true
}, },
"@formbricks/web#test": { "@formbricks/web#test": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"] "dependsOn": ["@formbricks/logger#build"]
}, },
"@formbricks/web#test:coverage": { "@formbricks/web#test:coverage": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"] "dependsOn": ["@formbricks/logger#build"]
}, },
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
@@ -208,7 +205,7 @@
}, },
"db:setup": { "db:setup": {
"cache": false, "cache": false,
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"], "dependsOn": ["@formbricks/logger#build"],
"outputs": [] "outputs": []
}, },
"db:start": { "db:start": {