Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes 0b5278a67c feat: enhance UI components with SettingsCard for better layout and organization
- Updated PricingTable to include max-width for alerts.
- Integrated SettingsCard in FeedbackDirectoriesPage and WorkspaceFeedbackSourcesPage for improved presentation of upgrade prompts.
2026-05-19 21:46:56 +02:00
26 changed files with 52 additions and 190 deletions
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings/workspace/general`,
href: `/workspaces/${workspace.id}/settings`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +467,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
<GoBackButton />
</div>
{/* Settings sidebar content */}
@@ -335,7 +335,6 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -374,14 +373,12 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,11 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
return <>{props.children}</>;
};
@@ -1,54 +0,0 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -1,12 +0,0 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,11 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
export default APIKeysPage;
@@ -1,18 +1,3 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
export default PricingPage;
@@ -1,7 +1,6 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -13,9 +12,8 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -1,10 +1,9 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -12,19 +11,15 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1,11 +1 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
@@ -1,4 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -27,9 +26,8 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -1,11 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
export default TeamsPage;
@@ -1,9 +1,7 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -11,7 +11,6 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -26,7 +25,6 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -105,8 +103,6 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -104,11 +104,7 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -49,7 +49,18 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
return {
survey: {
@@ -73,6 +84,8 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
@@ -457,7 +457,7 @@ export const PricingTable = ({
)}
{isStripeSetupIncomplete && hasBillingRights && (
<Alert variant="warning">
<Alert variant="warning" className="max-w-4xl">
<AlertTitle>{t("workspace.settings.billing.stripe_setup_incomplete")}</AlertTitle>
<AlertDescription>
{t("workspace.settings.billing.stripe_setup_incomplete_description")}
@@ -469,7 +469,7 @@ export const PricingTable = ({
)}
{currentCloudPlan === "custom" && (
<Alert>
<Alert className="max-w-4xl">
<AlertTitle>{t("workspace.settings.billing.custom_plan_title")}</AlertTitle>
<AlertDescription>{t("workspace.settings.billing.custom_plan_description")}</AlertDescription>
</Alert>
@@ -1,3 +1,4 @@
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
@@ -23,7 +24,9 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<div className="flex items-center justify-center">
<SettingsCard
title={t("workspace.settings.feedback_directories.title")}
description={t("workspace.settings.feedback_directories.description")}>
<UpgradePrompt
title={t("workspace.settings.feedback_directories.upgrade_prompt_title")}
description={t("workspace.settings.feedback_directories.upgrade_prompt_description")}
@@ -43,7 +46,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
},
]}
/>
</div>
</SettingsCard>
</PageContentWrapper>
);
}
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getSurveys } from "@/lib/survey/service";
@@ -6,6 +7,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils";
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";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { ConnectorsSection } from "./components/connectors-page-client";
@@ -33,6 +35,7 @@ export const WorkspaceFeedbackSourcesPage = async (
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
const pageTitle = t("workspace.unify.feedback_sources");
if (!hasAccess) {
return notFound();
}
@@ -41,7 +44,10 @@ export const WorkspaceFeedbackSourcesPage = async (
if (!isFeedbackDirectoriesAllowed) {
return (
<PageContentWrapper>
<div className="flex items-center justify-center">
<PageHeader pageTitle={pageTitle} />
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}>
<UpgradePrompt
title={t("workspace.unify.upgrade_prompt_title")}
description={t("workspace.unify.upgrade_prompt_description")}
@@ -61,7 +67,7 @@ export const WorkspaceFeedbackSourcesPage = async (
},
]}
/>
</div>
</SettingsCard>
</PageContentWrapper>
);
}
@@ -5,14 +5,9 @@ import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
interface GoBackButtonProps {
url?: string;
}
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
export const GoBackButton = ({ url }: { url?: string }) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Button
size="sm"
@@ -22,7 +17,6 @@ export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
router.push(url);
return;
}
router.back();
}}>
<ArrowLeftIcon />
+1 -2
View File
@@ -55,8 +55,7 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
when using the default release name.
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
endpoint.
- The generated app secret supplies `CUBEJS_API_SECRET` by default. If you disable generated secrets,
provide it through your existing secret management flow.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
-9
View File
@@ -289,15 +289,6 @@ true
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.cubejsApiSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.envoy.gatewayClassName" -}}
{{- if .Values.envoy.formbricks.gatewayClass.name -}}
{{- .Values.envoy.formbricks.gatewayClass.name | trunc 63 | trimSuffix "-" -}}
@@ -70,12 +70,8 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if or .Values.cube.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
{{- if .Values.cube.envFrom }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:
name: {{ template "formbricks.name" . }}-app-secrets
{{- end }}
{{- range $value := .Values.cube.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
-2
View File
@@ -5,7 +5,6 @@
{{- $redisPassword := include "formbricks.redisPassword" . }}
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
{{- $cubejsApiSecret := include "formbricks.cubejsApiSecret" . }}
---
apiVersion: v1
kind: Secret
@@ -32,7 +31,6 @@ data:
{{- end }}
HUB_API_KEY: {{ $hubApiKey | b64enc }}
credential: {{ printf "Bearer %s" $hubApiKey | b64enc }}
CUBEJS_API_SECRET: {{ $cubejsApiSecret | b64enc }}
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
+2 -3
View File
@@ -580,9 +580,8 @@ cube:
type: ClusterIP
port: 4000
# The generated app secret supplies CUBEJS_API_SECRET when secret.enabled=true.
# Secret values such as CUBEJS_DB_* should be supplied through envFrom or another secret-management
# flow.
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
# through envFrom or another secret-management flow.
envFrom: []
env:
+3 -4
View File
@@ -116,8 +116,7 @@ Cube is part of the baseline Formbricks v5 stack and is bundled with the chart b
- set `cube.enabled: false` to skip the bundled Cube deployment
- point the app at your external endpoint via `deployment.env.CUBEJS_API_URL`
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom` if you disable generated
secrets
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom`
## 4. Upgrade The Deployment
@@ -135,8 +134,8 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the
- `HUB_API_KEY` is present
- your edge rate-limiting plan is in place
- any required `AI_*` variables are added
- `CUBEJS_API_SECRET` is configured (the generated app secret supplies it by default; provide an external
endpoint if you set `cube.enabled: false`)
- `CUBEJS_API_SECRET` is configured (Cube is bundled by default; provide an external endpoint if you set
`cube.enabled: false`)
## 5. Key Values