Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/helm-chart-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Helm Chart Validation

on:
workflow_call:

permissions:
contents: read

jobs:
validate:
name: Validate Helm Chart
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit

- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false

- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: v3.15.4

- name: Validate chart renders
shell: bash
run: |
set -euo pipefail

chart="charts/formbricks"
render_dir="$(mktemp -d)"
trap 'rm -rf "$render_dir"' EXIT

helm lint "$chart" --set formbricks.webappUrl=https://qa.example.com

helm template qa "$chart" \
--set formbricks.webappUrl=https://qa.example.com \
--show-only templates/migration-job.yaml > "$render_dir/default.yaml"

grep -q 'packages/database/dist/scripts/wait-for-database.js' "$render_dir/default.yaml"
grep -q 'packages/database/dist/scripts/apply-migrations.js' "$render_dir/default.yaml"
grep -q 'argocd.argoproj.io/sync-wave: "-1"' "$render_dir/default.yaml"
test "$(grep -c '^ image: ' "$render_dir/default.yaml")" -eq 2
test "$(grep '^ image: ' "$render_dir/default.yaml" | sort -u | wc -l | tr -d ' ')" -eq 1
test "$(grep -c 'name: formbricks-app-secrets' "$render_dir/default.yaml")" -eq 2
! grep -q '^ env:$' "$render_dir/default.yaml"

helm template qa "$chart" \
--set formbricks.webappUrl=https://qa.example.com \
--set migration.waitForDatabase.enabled=false \
--show-only templates/migration-job.yaml > "$render_dir/disabled.yaml"
! grep -q 'wait-for-database' "$render_dir/disabled.yaml"
grep -q 'packages/database/dist/scripts/apply-migrations.js' "$render_dir/disabled.yaml"

helm template qa "$chart" \
--set formbricks.webappUrl=https://qa.example.com \
--set postgresql.enabled=false \
--set-string postgresql.externalDatabaseUrl=postgresql://user:password@external-postgresql:5432/formbricks \
--show-only templates/migration-job.yaml > "$render_dir/external.yaml"
grep -q 'packages/database/dist/scripts/wait-for-database.js' "$render_dir/external.yaml"

helm template qa "$chart" \
--set formbricks.webappUrl=https://qa.example.com \
--set-string deployment.env.MIGRATE_DATABASE_URL=postgresql://user:password@migrate-postgresql:5432/formbricks \
--show-only templates/migration-job.yaml > "$render_dir/migrate-url.yaml"
test "$(grep -c 'name: MIGRATE_DATABASE_URL' "$render_dir/migrate-url.yaml")" -eq 2
test "$(grep -c '^ env:' "$render_dir/migrate-url.yaml")" -eq 2

helm template qa "$chart" \
--set formbricks.webappUrl=https://qa.example.com \
--show-only charts/postgresql/templates/primary/statefulset.yaml > "$render_dir/postgresql.yaml"
grep -q 'argocd.argoproj.io/sync-wave: "-2"' "$render_dir/postgresql.yaml"
6 changes: 5 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@ jobs:
uses: ./.github/workflows/build-web.yml
secrets: inherit

helm-chart-validation:
name: Validate Helm Chart
uses: ./.github/workflows/helm-chart-validation.yml

e2e-test:
name: Run E2E Tests
uses: ./.github/workflows/e2e.yml
secrets: inherit

required:
name: PR Check Summary
needs: [lint, test, build, e2e-test]
needs: [lint, test, build, helm-chart-validation, e2e-test]
if: always()
runs-on: ubuntu-latest
permissions:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,11 @@ export const MainNavigation = ({
<div>
{/* Logo and Toggle */}

<div className="flex items-center justify-between px-3 pb-4">
<div
className={cn(
"flex items-center px-3 pb-4",
isCollapsed ? "justify-center" : "justify-between"
)}>
{!isCollapsed && (
<Link
href={mainNavigationLink}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,13 @@ export const NavigationLink = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
<li className={cn("mb-1 ml-1 rounded-l-md py-2 text-sm", collapsedColorClass)}>
{disabled ? (
<div className="flex items-center">{children}</div>
<div className="flex items-center justify-center">{children}</div>
) : (
<Link href={href}>{children}</Link>
<Link href={href} className="flex items-center justify-center">
{children}
</Link>
)}
</li>
</TooltipTrigger>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { resetDb } from "@/integration/reset-db";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { auth } from "@/modules/auth/lib/auth";
import { sendDeleteAccountConfirmationEmail } from "@/modules/email";
import { requestSsoAccountDeletionEmail } from "./better-auth-account-deletion-request";
Expand All @@ -11,6 +12,20 @@ import { requestSsoAccountDeletionEmail } from "./better-auth-account-deletion-r
const { getSessionMock } = vi.hoisted(() => ({ getSessionMock: vi.fn() }));
vi.mock("@/modules/auth/lib/session", () => ({ getSession: getSessionMock }));

// Toggle IS_FORMBRICKS_CLOUD per test to cover both post-deletion redirect targets; every other
// constant (WEBAPP_URL, etc.) stays real so the Better Auth harness is untouched (auth.ts does not
// read IS_FORMBRICKS_CLOUD).
const { constantsOverrides } = vi.hoisted(() => ({ constantsOverrides: { isFormbricksCloud: false } }));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
get IS_FORMBRICKS_CLOUD() {
return constantsOverrides.isFormbricksCloud;
},
};
});

// @/modules/email is mocked in integration/setup.ts (captures mail instead of hitting SMTP); grab the
// auto-mocked sendDeleteAccountConfirmationEmail to assert the action calls it with the callback link.
const sendDeleteAccountConfirmationEmailMock = vi.mocked(sendDeleteAccountConfirmationEmail);
Expand Down Expand Up @@ -39,6 +54,7 @@ const createVerifiedUser = async (email: string, password: string): Promise<stri
beforeEach(async () => {
await resetDb();
vi.clearAllMocks();
constantsOverrides.isFormbricksCloud = false;
});

describe("requestSsoAccountDeletionEmail (real Postgres)", () => {
Expand All @@ -65,7 +81,8 @@ describe("requestSsoAccountDeletionEmail (real Postgres)", () => {
const mailArgs = sendDeleteAccountConfirmationEmailMock.mock.calls[0][0];
expect(mailArgs.email).toBe(email);
expect(mailArgs.deleteLink).toContain("/api/auth/delete-user/callback?token=");
expect(mailArgs.deleteLink).toContain("callbackURL=/");
// Self-hosted (IS_FORMBRICKS_CLOUD is false under test): the callback returns to the login page.
expect(mailArgs.deleteLink).toContain(`callbackURL=${encodeURIComponent("/auth/login")}`);
const token = new URL(mailArgs.deleteLink).searchParams.get("token");
expect(token).toBeTruthy();

Expand All @@ -87,6 +104,23 @@ describe("requestSsoAccountDeletionEmail (real Postgres)", () => {
expect(await prisma.user.findUnique({ where: { id: userId } })).toBeNull();
});

test("on Formbricks Cloud, the emailed link returns to the account-deletion survey (ENG-1780)", async () => {
constantsOverrides.isFormbricksCloud = true;
const email = "ssocloud@example.com";
const userId = await createVerifiedUser(email, "Passw0rd!");
await prisma.user.update({ where: { id: userId }, data: { identityProvider: "google" } });
getSessionMock.mockResolvedValue({ user: { id: userId, email } });

await requestSsoAccountDeletionEmail();

expect(sendDeleteAccountConfirmationEmailMock).toHaveBeenCalledTimes(1);
const mailArgs = sendDeleteAccountConfirmationEmailMock.mock.calls[0][0];
// Cloud: the callback redirects to the offboarding survey instead of the login page.
expect(mailArgs.deleteLink).toContain(
`callbackURL=${encodeURIComponent(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL)}`
);
});

test("rejects a credential (email-identity) user — they must confirm with their password", async () => {
const email = "credrequest@example.com";
const userId = await createVerifiedUser(email, "Passw0rd!"); // identityProvider defaults to "email"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import crypto from "node:crypto";
import { prisma } from "@formbricks/database";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import type { TUserLocale } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { auth } from "@/modules/auth/lib/auth";
import { getSession } from "@/modules/auth/lib/session";
Expand Down Expand Up @@ -66,7 +67,13 @@ export const requestSsoAccountDeletionEmail = async (): Promise<void> => {
expiresAt: new Date(Date.now() + DELETE_ACCOUNT_LINK_VALIDITY_MS),
});

const deleteLink = `${WEBAPP_URL}/api/auth/delete-user/callback?token=${token}&callbackURL=/`;
// Match the credential delete path's post-deletion redirect (DeleteAccountModal): survey on
// Formbricks Cloud, /auth/login otherwise. Better Auth's delete-user/callback only accepts
// same-origin callbackURLs (trustedOrigins); the survey is served from the Cloud app's own origin,
// so it passes there. (It is rejected when WEBAPP_URL differs from the survey origin — e.g. a local
// build with the Cloud flag forced on.)
const callbackURL = IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
const deleteLink = `${WEBAPP_URL}/api/auth/delete-user/callback?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`;

await sendDeleteAccountConfirmationEmail({
email,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/modules/ee/analysis/charts/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ describe("chart Cube actions", () => {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sourceType"],
},
name: "Responses by Source Type",
});

const result = await generateAIChartAction({
Expand All @@ -173,6 +174,7 @@ describe("chart Cube actions", () => {
dimensions: ["FeedbackRecords.sourceType"],
},
data: [{ "FeedbackRecords.count": 1 }],
suggestedName: "Responses by Source Type",
});
expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({
query: {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/modules/ee/analysis/charts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export const generateAIChartAction = authenticatedActionClient
source: "charts.generateAIChartAction",
});

const { chartType, query } = await generateAIChartQuery({
const { chartType, query, name } = await generateAIChartQuery({
organizationId,
prompt: parsedInput.prompt,
});
Expand All @@ -341,6 +341,8 @@ export const generateAIChartAction = authenticatedActionClient
query: validatedQuery,
chartType,
data: Array.isArray(data) ? data : [],
// Prefills the chart-name input (only when the user hasn't typed a name).
suggestedName: name,
};
}
);
Expand Down
58 changes: 28 additions & 30 deletions apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/po
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
CHART_NOT_ENRICHED_COLOR,
PIVOTED_MEASURE_KEY,
PIVOTED_VALUE_KEY,
formatCellValue,
formatXAxisTick,
getSemanticDimensionColor,
getSentimentMeasureColor,
pivotMeasuresToCategories,
preparePieData,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
Expand All @@ -22,7 +23,6 @@ import {
formatCubeColumnHeader,
getMeasureAxisLabel,
getTranslatedDimensionValueLabel,
isNotEnrichedDimensionValue,
sortMeasureIdsForCategoryAxis,
sortRowsByEnumDimension,
} from "@/modules/ee/analysis/lib/schema-definition";
Expand Down Expand Up @@ -176,12 +176,14 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
);
}

// Sentiment count measures carry semantic colors keyed by enum value (red-ish for very negative →
// green for very positive); every other series takes the generic palette by index.
const chartConfig: ChartConfig = Object.fromEntries(
dataKeys.map((key, i) => [
key,
{
label: formatCubeColumnHeader(key, t),
color: CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length],
color: getSentimentMeasureColor(key) ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length],
},
])
);
Expand Down Expand Up @@ -237,9 +239,9 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
? sortedData
: sortedData.map((row, index) => ({
...row,
fill: isNotEnrichedDimensionValue(xAxisKey, row[xAxisKey])
? CHART_NOT_ENRICHED_COLOR
: CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length],
fill:
getSemanticDimensionColor(xAxisKey, row[xAxisKey]) ??
CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length],
}));

return (
Expand All @@ -255,23 +257,19 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
hasCategoryAxis={hasCategoryAxis}
xAxisTickFormatter={formatDimensionValue}
chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}>
{dataKeys.map((key, i) => {
const fallbackColor =
chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
return (
<Bar key={key} dataKey={key} fill={fallbackColor} radius={4}>
{!isMultiMeasure && (
<LabelList
dataKey={key}
position="top"
className="fill-foreground"
fontSize={11}
formatter={(value: unknown) => formatCellValue(value)}
/>
)}
</Bar>
);
})}
{dataKeys.map((key) => (
<Bar key={key} dataKey={key} fill={chartConfig[key]?.color} radius={4}>
{!isMultiMeasure && (
<LabelList
dataKey={key}
position="top"
className="fill-foreground"
fontSize={11}
formatter={(value: unknown) => formatCellValue(value)}
/>
)}
</Bar>
))}
</CartesianChart>
);
}
Expand All @@ -288,8 +286,8 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
hasCategoryAxis={hasCategoryAxis}
xAxisTickFormatter={formatDimensionValue}>
<defs>
{dataKeys.map((key, i) => {
const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
{dataKeys.map((key) => {
const color = chartConfig[key]?.color;
return (
<linearGradient key={key} id={`${gradientIdPrefix}-line-${key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
Expand All @@ -298,8 +296,8 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
);
})}
</defs>
{dataKeys.map((key, i) => {
const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
{dataKeys.map((key) => {
const color = chartConfig[key]?.color;
return (
<Area
key={key}
Expand Down Expand Up @@ -328,13 +326,13 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
showLegend
hasCategoryAxis={hasCategoryAxis}
xAxisTickFormatter={formatDimensionValue}>
{dataKeys.map((key, i) => (
{dataKeys.map((key) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
stroke={chartConfig[key]?.color}
fill={chartConfig[key]?.color}
fillOpacity={0.4}
strokeWidth={2}
connectNulls={false}
Expand Down
Loading
Loading