diff --git a/.env.example b/.env.example index 664abd3bf7..b3ed82c802 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,8 @@ UNKEY_ROOT_KEY= # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # It's used automatically by Sentry during the build for authentication when uploading source maps. # SENTRY_AUTH_TOKEN= +# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard +# SENTRY_ENVIRONMENT= # Configure the minimum role for user management from UI(owner, manager, disabled) # USER_MANAGEMENT_MINIMUM_ROLE="manager" diff --git a/.github/actions/upload-sentry-sourcemaps/action.yml b/.github/actions/upload-sentry-sourcemaps/action.yml new file mode 100644 index 0000000000..e8510aa2f2 --- /dev/null +++ b/.github/actions/upload-sentry-sourcemaps/action.yml @@ -0,0 +1,121 @@ +name: 'Upload Sentry Sourcemaps' +description: 'Extract sourcemaps from Docker image and upload to Sentry' + +inputs: + docker_image: + description: 'Docker image to extract sourcemaps from' + required: true + release_version: + description: 'Sentry release version (e.g., v1.2.3)' + required: true + sentry_auth_token: + description: 'Sentry authentication token' + required: true + +runs: + using: 'composite' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate Sentry auth token + shell: bash + run: | + set -euo pipefail + echo "๐Ÿ” Validating Sentry authentication token..." + + # Assign token to local variable for secure handling + SENTRY_TOKEN="${{ inputs.sentry_auth_token }}" + + # Test the token by making a simple API call to Sentry + response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \ + -H "Authorization: Bearer $SENTRY_TOKEN" \ + "https://sentry.io/api/0/organizations/formbricks/") + + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" != "200" ]; then + echo "โŒ Error: Invalid Sentry auth token (HTTP $http_code)" + echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions." + if [ -f /tmp/sentry_response.json ]; then + echo "Response body:" + cat /tmp/sentry_response.json + fi + exit 1 + fi + + echo "โœ… Sentry auth token validated successfully" + + # Clean up temp file + rm -f /tmp/sentry_response.json + + - name: Extract sourcemaps from Docker image + shell: bash + run: | + set -euo pipefail + echo "๐Ÿ“ฆ Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}" + + # Create temporary container from the image and capture its ID + echo "Creating temporary container..." + CONTAINER_ID=$(docker create "${{ inputs.docker_image }}") + echo "Container created with ID: $CONTAINER_ID" + + # Set up cleanup function to ensure container is removed on script exit + cleanup_container() { + # Capture the current exit code to preserve it + local original_exit_code=$? + + echo "๐Ÿงน Cleaning up Docker container..." + + # Remove the container if it exists (ignore errors if already removed) + if [ -n "$CONTAINER_ID" ]; then + docker rm -f "$CONTAINER_ID" 2>/dev/null || true + echo "Container $CONTAINER_ID removed" + fi + + # Exit with the original exit code to preserve script success/failure status + exit $original_exit_code + } + + # Register cleanup function to run on script exit (success or failure) + trap cleanup_container EXIT + + # Extract .next directory containing sourcemaps + docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next + + # Verify sourcemaps exist + if [ ! -d "./extracted-next/static/chunks" ]; then + echo "โŒ Error: .next/static/chunks directory not found in Docker image" + echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/" + exit 1 + fi + + sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l) + echo "โœ… Found $sourcemap_count sourcemap files" + + if [ "$sourcemap_count" -eq 0 ]; then + echo "โŒ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled." + exit 1 + fi + + - name: Create Sentry release and upload sourcemaps + uses: getsentry/action-release@v3 + env: + SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }} + SENTRY_ORG: formbricks + SENTRY_PROJECT: formbricks-cloud + with: + environment: production + version: ${{ inputs.release_version }} + sourcemaps: './extracted-next/' + + - name: Clean up extracted files + shell: bash + if: always() + run: | + set -euo pipefail + # Clean up extracted files + rm -rf ./extracted-next + echo "๐Ÿงน Cleaned up extracted files" diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml index 68f45a88b5..6df33e7dd8 100644 --- a/.github/workflows/formbricks-release.yml +++ b/.github/workflows/formbricks-release.yml @@ -32,3 +32,25 @@ jobs: with: VERSION: v${{ needs.docker-build.outputs.VERSION }} ENVIRONMENT: "prod" + + upload-sentry-sourcemaps: + name: Upload Sentry Sourcemaps + runs-on: ubuntu-latest + permissions: + contents: read + needs: + - docker-build + - deploy-formbricks-cloud + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Upload Sentry Sourcemaps + uses: ./.github/actions/upload-sentry-sourcemaps + continue-on-error: true + with: + docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} + release_version: v${{ needs.docker-build.outputs.VERSION }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/upload-sentry-sourcemaps.yml b/.github/workflows/upload-sentry-sourcemaps.yml new file mode 100644 index 0000000000..7af92ebc10 --- /dev/null +++ b/.github/workflows/upload-sentry-sourcemaps.yml @@ -0,0 +1,46 @@ +name: Upload Sentry Sourcemaps (Manual) + +on: + workflow_dispatch: + inputs: + docker_image: + description: "Docker image to extract sourcemaps from" + required: true + type: string + release_version: + description: "Release version (e.g., v1.2.3)" + required: true + type: string + tag_version: + description: "Docker image tag (leave empty to use release_version)" + required: false + type: string + +permissions: + contents: read + +jobs: + upload-sourcemaps: + name: Upload Sourcemaps to Sentry + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Set Docker Image + run: | + if [ -n "${{ inputs.tag_version }}" ]; then + echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV + else + echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV + fi + + - name: Upload Sourcemaps to Sentry + uses: ./.github/actions/upload-sentry-sourcemaps + with: + docker_image: ${{ env.DOCKER_IMAGE }} + release_version: ${{ inputs.release_version }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index e9729940cf..48dadbeb2a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate # Install necessary build tools and compilers RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 -# BuildKit secret handling without hardcoded fallback values -# This approach relies entirely on secrets passed from GitHub Actions -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 +# Copy the secrets handling script +COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh +RUN chmod +x /tmp/read-secrets.sh # Increase Node.js memory limit as a regular build argument ARG NODE_OPTIONS="--max_old_space_size=4096" @@ -62,6 +50,9 @@ RUN touch apps/web/.env # Install the dependencies RUN pnpm install --ignore-scripts +# Build the database package first +RUN pnpm build --filter=@formbricks/database + # Build the project using our secret reader script # This mounts the secrets only during this build step without storing them in layers RUN --mount=type=secret,id=database_url \ @@ -106,20 +97,8 @@ 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 RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma -COPY --from=installer /app/packages/database/package.json ./packages/database/package.json -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/packages/database/dist ./packages/database/dist +RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist 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 @@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/zod ./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 +# 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 -ENV HOSTNAME "0.0.0.0" -ENV NODE_ENV="production" +ENV HOSTNAME="0.0.0.0" USER nextjs # Prepare volume for uploads @@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/ RUN mkdir -p /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection -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 \ No newline at end of file +CMD ["/home/nextjs/start.sh"] \ No newline at end of file diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx index 40ab57335f..fb33d1991c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx @@ -94,6 +94,7 @@ describe("LandingSidebar component", () => { organizationId: "o1", redirect: true, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); }); }); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx index ce5e8b7b4a..f50e589875 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx @@ -130,6 +130,7 @@ export const LandingSidebar = ({ organizationId: organization.id, redirect: true, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); }} icon={}> diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx index 0809609434..56df681c75 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx @@ -221,7 +221,6 @@ describe("MainNavigation", () => { vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); // Set up localStorage spy on the mocked localStorage - const removeItemSpy = vi.spyOn(window.localStorage, "removeItem"); render(); @@ -243,23 +242,18 @@ describe("MainNavigation", () => { const logoutButton = screen.getByText("common.logout"); await userEvent.click(logoutButton); - // Verify localStorage.removeItem is called with the correct key - expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id"); - expect(mockSignOut).toHaveBeenCalledWith({ reason: "user_initiated", redirectUrl: "/auth/login", organizationId: "org1", redirect: false, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); await waitFor(() => { expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); }); - - // Clean up spy - removeItemSpy.mockRestore(); }); test("handles organization switching", async () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index d492cfa87b..8a955cbf34 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -4,7 +4,6 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import FBLogo from "@/images/formbricks-wordmark.svg"; import { cn } from "@/lib/cn"; -import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { getAccessFlags } from "@/lib/membership/utils"; import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; @@ -391,14 +390,13 @@ export const MainNavigation = ({ { - localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); - const route = await signOutWithAudit({ reason: "user_initiated", redirectUrl: "/auth/login", organizationId: organization.id, redirect: false, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings }} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts index 065a9f9309..cdfd3efeab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co import { rateLimit } from "@/lib/utils/rate-limit"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; -import { sendVerificationNewEmail } from "@/modules/email"; +import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { @@ -162,3 +162,21 @@ 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 }; + } + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx index ea6c290c8b..a9a779e55b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import toast from "react-hot-toast"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { resetPasswordAction, updateUserAction } from "../actions"; import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; const mockUser = { @@ -24,6 +24,8 @@ const mockUser = { objective: "other", } as unknown as TUser; +vi.mock("next-auth/react", () => ({ signOut: vi.fn() })); + // Mock window.location.reload const originalLocation = window.location; beforeEach(() => { @@ -35,6 +37,11 @@ beforeEach(() => { vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ updateUserAction: vi.fn(), + resetPasswordAction: vi.fn(), +})); + +vi.mock("@/modules/auth/forgot-password/actions", () => ({ + forgotPasswordAction: vi.fn(), })); afterEach(() => { @@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => { test("renders with initial user data and updates successfully", async () => { vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); - render(); + render( + + ); const nameInput = screen.getByPlaceholderText("common.full_name"); expect(nameInput).toHaveValue(mockUser.name); @@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => { const errorMessage = "Update failed"; vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); - render(); + render( + + ); const nameInput = screen.getByPlaceholderText("common.full_name"); await userEvent.clear(nameInput); @@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => { }); test("update button is disabled initially and enables on change", async () => { - render(); + render( + + ); const updateButton = screen.getByText("common.update"); expect(updateButton).toBeDisabled(); @@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => { await userEvent.type(nameInput, " updated"); expect(updateButton).toBeEnabled(); }); + + test("reset password button works", async () => { + vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading"); + }); + }); + + test("reset password button handles error correctly", async () => { + const errorMessage = "Reset failed"; + vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + test("reset password button shows loading state", async () => { + vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + expect(resetButton).toBeDisabled(); + }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx index 2b794c20cf..8c85a2d780 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx @@ -14,6 +14,7 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { ChevronDownIcon } from "lucide-react"; @@ -22,7 +23,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { resetPasswordAction, updateUserAction } from "../actions"; // Schema & types const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ @@ -30,13 +31,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: }); type TEditProfileNameForm = z.infer; +interface IEditProfileDetailsFormProps { + user: TUser; + isPasswordResetEnabled?: boolean; + emailVerificationDisabled: boolean; +} + export const EditProfileDetailsForm = ({ user, + isPasswordResetEnabled, emailVerificationDisabled, -}: { - user: TUser; - emailVerificationDisabled: boolean; -}) => { +}: IEditProfileDetailsFormProps) => { const { t } = useTranslate(); const form = useForm({ @@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({ }); const { isSubmitting, isDirty } = form.formState; + + const [isResettingPassword, setIsResettingPassword] = useState(false); const [showModal, setShowModal] = useState(false); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); @@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({ redirectUrl: "/email-change-without-verification-success", redirect: true, callbackUrl: "/email-change-without-verification-success", + clearEnvironmentId: true, }); return; } @@ -121,6 +129,28 @@ export const EditProfileDetailsForm = ({ } }; + const handleResetPassword = async () => { + setIsResettingPassword(true); + + const result = await resetPasswordAction(); + if (result?.data) { + toast.success(t("auth.forgot-password.email-sent.heading")); + + await signOutWithAudit({ + reason: "password_reset", + redirectUrl: "/auth/login", + redirect: true, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(t(errorMessage)); + } + + setIsResettingPassword(false); + }; + return ( <> @@ -205,6 +235,26 @@ export const EditProfileDetailsForm = ({ )} /> + {isPasswordResetEnabled && ( +
+ +

+ {t("auth.forgot-password.reset_password_description")} +

+
+ + +
+
+ )} + + ); + } + + if (plan.id === projectFeatureKeys.STARTUP) { if (organization.billing.plan === projectFeatureKeys.FREE) { return ( ); } @@ -100,15 +105,20 @@ export const PricingCard = ({ ); } - return <>; + return null; }, [ isCurrentPlan, loading, onUpgrade, organization.billing.plan, + plan.CTA, + plan.featured, + plan.href, plan.id, projectFeatureKeys.ENTERPRISE, projectFeatureKeys.FREE, + projectFeatureKeys.STARTUP, + t, ]); return ( @@ -147,7 +157,7 @@ export const PricingCard = ({ : plan.price.yearly : t(plan.price.monthly)}

- {plan.name !== "Enterprise" && ( + {plan.id !== projectFeatureKeys.ENTERPRISE && (

/ {planPeriod === "monthly" ? "Month" : "Year"} @@ -171,16 +181,9 @@ export const PricingCard = ({ {t("environments.settings.billing.manage_subscription")} )} - - {organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && ( - - )}