Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes
f356e86729 vibed settings unification 2026-04-12 20:48:07 +02:00
235 changed files with 2291 additions and 2348 deletions

View File

@@ -1,13 +1 @@
#!/usr/bin/env sh
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
pnpm lint-staged

View File

@@ -26,7 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(membership?.role);
const { isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return (
@@ -45,7 +45,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]}

View File

@@ -0,0 +1,24 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
const MainNavLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect("/auth/login");
}
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return <EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>;
};
export default MainNavLayout;

View File

@@ -107,9 +107,7 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
isLoading={isLoading}
/>

View File

@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
const meta = getSurveySummaryMeta([], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {

View File

@@ -51,32 +51,7 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -85,15 +60,9 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
if (response.ttc?._total) {
ttcResponseCount++;
return acc + responseTtcTotal;
return acc + response.ttc._total;
}
return acc;
}, 0);
@@ -148,16 +117,12 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
responseCounts[elementId]++;
}
});
@@ -1009,8 +974,10 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
return {
meta,

View File

@@ -0,0 +1,28 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { SettingsSidebar } from "@/modules/settings/components/settings-sidebar";
const SettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { project, organization, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
return (
<div className="flex h-screen min-h-screen overflow-hidden">
<SettingsSidebar
environmentId={params.environmentId}
projectName={project.name}
organizationName={organization.name}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
/>
<div className="flex flex-1 flex-col overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -0,0 +1,139 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "@/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditAlerts";
import { IntegrationsTip } from "@/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/environments/[environmentId]/settings/(account)/notifications/types";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings => {
const newNotificationSettings: TUserNotificationSettings = {
alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
for (const environment of project.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false;
}
}
}
}
return newNotificationSettings;
};
const getMemberships = async (userId: string): Promise<Membership[]> => {
const memberships = await prisma.membership.findMany({
where: {
userId,
role: { not: "billing" },
OR: [
{ role: { in: ["owner", "manager"] } },
{
organization: {
projects: {
some: {
projectTeams: { some: { team: { teamUsers: { some: { userId } } } } },
},
},
},
},
],
},
select: {
organization: {
select: {
id: true,
name: true,
projects: {
where: {
OR: [
{
organization: {
memberships: { some: { userId, role: { in: ["owner", "manager"] } } },
},
},
{ projectTeams: { some: { team: { teamUsers: { some: { userId } } } } } },
],
},
select: {
id: true,
name: true,
environments: {
where: { type: "production" },
select: {
id: true,
surveys: { select: { id: true, name: true } },
},
},
},
},
},
},
},
});
return memberships;
};
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!memberships) {
throw new ResourceNotFoundError(t("common.membership"), null);
}
if (user?.notificationSettings) {
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")} />
<SettingsCard
title={t("environments.settings.notifications.email_alerts_surveys")}
description={t(
"environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"
)}>
<EditAlerts
memberships={memberships}
user={user}
environmentId={params.environmentId}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
</PageContentWrapper>
);
};
export default Page;

View File

@@ -0,0 +1,99 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { DeleteAccount } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount";
import { EditProfileDetailsForm } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const t = await getTranslate();
const { environmentId } = params;
const { session } = await getEnvironmentAuth(params.environmentId);
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")} />
{user && (
<div>
<SettingsCard
title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm
user={user}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard>
{user.identityProvider === "email" && (
<SettingsCard
title={t("common.security")}
description={t("environments.settings.profile.security_description")}>
{!isTwoFactorAuthEnabled && !user.twoFactorEnabled ? (
<UpgradePrompt
title={t("environments.settings.profile.unlock_two_factor_authentication")}
description={t("environments.settings.profile.two_factor_authentication_description")}
buttons={[
{
text: IS_FORMBRICKS_CLOUD
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/organization/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/organization/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
) : (
<AccountSecurity user={user} />
)}
</SettingsCard>
)}
<SettingsCard
title={t("environments.settings.profile.delete_account")}
description={t("environments.settings.profile.confirm_delete_account")}>
<DeleteAccount
session={session}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
</div>
)}
</PageContentWrapper>
);
};
export default Page;

View File

@@ -0,0 +1,43 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ApiKeyList } from "@/modules/organization/settings/api-keys/components/api-key-list";
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { currentUserMembership, organization, session } = await getEnvironmentAuth(params.environmentId);
const [projects, locale] = await Promise.all([
getProjectsByOrganizationId(organization.id),
getUserLocale(session.user.id),
]);
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
if (!canAccessApiKeys) throw new Error(t("common.not_authorized"));
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
<SettingsCard
title={t("common.api_keys")}
description={t("environments.settings.api_keys.api_keys_description")}>
<ApiKeyList
organizationId={organization.id}
locale={locale ?? DEFAULT_LOCALE}
isReadOnly={!canAccessApiKeys}
projects={projects}
/>
</SettingsCard>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -0,0 +1,64 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { PricingTable } from "@/modules/ee/billing/components/pricing-table";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getStripeBillingCatalogDisplay } from "@/modules/ee/billing/lib/stripe-billing-catalog";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { organization, isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
if (!IS_FORMBRICKS_CLOUD) {
notFound();
}
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([
getCloudBillingDisplayContext(organization.id),
getStripeBillingCatalogDisplay(),
]);
const organizationWithSyncedBilling = {
...organization,
billing: cloudBillingDisplayContext.billing,
};
const [responseCount, projectCount] = await Promise.all([
getMonthlyOrganizationResponseCount(organization.id),
getOrganizationProjectsCount(organization.id),
]);
const hasBillingRights = !isMember;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
<PricingTable
organization={organizationWithSyncedBilling}
environmentId={params.environmentId}
responseCount={responseCount}
projectCount={projectCount}
hasBillingRights={hasBillingRights}
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
pendingChange={cloudBillingDisplayContext.pendingChange}
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
billingCatalog={billingCatalog}
/>
</PageContentWrapper>
);
};
export default Page;

Some files were not shown because too many files have changed in this diff Show More