This commit is contained in:
pandeymangg
2025-12-29 12:03:14 +05:30
parent 98cb2de02b
commit 3fb1d44c06
24 changed files with 848 additions and 6 deletions

View File

@@ -1,8 +1,11 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { FaviconCustomizationSettings } from "@/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsCard } from "../../components/SettingsCard";
@@ -17,12 +20,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return notFound();
}
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
params.environmentId
);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isOwnerOrManager = isManager || isOwner;
const result = await getSurveysWithSlugsByOrganizationId(organization.id);
if (!result.ok) {
throw new Error(t("common.something_went_wrong"));
@@ -41,6 +49,23 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/>
</PageHeader>
{!IS_STORAGE_CONFIGURED && (
<div className="max-w-4xl">
<Alert variant="warning">
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
</Alert>
</div>
)}
<FaviconCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
environmentId={params.environmentId}
isReadOnly={!isOwnerOrManager}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
<SettingsCard
title={t("environments.settings.domain.title")}
description={t("environments.settings.domain.description")}>

View File

@@ -322,6 +322,7 @@ checksums:
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/replace: 98b2268975b1a737b2e4ad837df96703
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
@@ -980,7 +981,16 @@ checksums:
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/uptime_sla_99: 25ca4060e575e1a7eee47fceb5576d7c
environments/settings/billing/website_surveys: f4d176cc66ffcc2abf44c0d5da1642e3
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
environments/settings/domain/description: f0b4d8c96da816f793cf1f4fdfaade34
environments/settings/domain/favicon_customization: 5c975be07a2a8e319c2c58c7d8c0c78f
environments/settings/domain/favicon_customization_description: 26c3310c2d85a3daefa3c19c01353efa
environments/settings/domain/favicon_for_link_surveys: b3bb8254f5ab2778db9091ddc3fffa8f
environments/settings/domain/favicon_removed_successfully: cebbf050b77dd7218b622d3a4cc4aa18
environments/settings/domain/favicon_saved_successfully: a6fbbe2f87b241bc5f661896299fc2b6
environments/settings/domain/favicon_size_hint: b3a35625898353380152de0be17dd5f7
environments/settings/domain/favicon_too_large: 82a014c0eba8ea8029fd76f61b795984
environments/settings/domain/no_pretty_urls: 2a83d8e399d325fcd4e2db14e45b1a86
environments/settings/domain/pretty_url: 10a4b387b6df844245fc842be29527e3
environments/settings/domain/project: e13002ec4570f3fcc2f050f5ce974294

View File

@@ -349,6 +349,7 @@
"remove": "Entfernen",
"remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"replace": "Ersetzen",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern",
@@ -1053,7 +1054,16 @@
"website_surveys": "Website-Umfragen"
},
"domain": {
"customize_favicon_description": "Lade ein individuelles Favicon hoch, um deine Link-Umfrage zu personalisieren und deine Markenpräsenz zu stärken.",
"customize_favicon_with_higher_plan": "Favicon mit einem höheren Plan anpassen",
"description": "Übersicht aller Umfragen mit Pretty URLs in Ihrer Organisation",
"favicon_customization": "Favicon",
"favicon_customization_description": "Passe das Favicon an, das in Browser-Tabs für deine Link-Umfragen angezeigt wird. Dies wirkt sich nicht auf die Admin-App aus.",
"favicon_for_link_surveys": "Favicon für Link-Umfragen",
"favicon_removed_successfully": "Favicon erfolgreich entfernt",
"favicon_saved_successfully": "Favicon erfolgreich gespeichert",
"favicon_size_hint": "Max. 100 KB, quadratische Bilder funktionieren am besten",
"favicon_too_large": "Favicon muss unter 100 KB sein",
"no_pretty_urls": "Noch keine Umfragen mit Pretty URLs konfiguriert.",
"pretty_url": "Pretty URL",
"project": "Projekt",

View File

@@ -349,6 +349,7 @@
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
"replace": "Replace",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license",
@@ -1053,7 +1054,16 @@
"website_surveys": "Website Surveys"
},
"domain": {
"customize_favicon_description": "Upload a custom favicon to personalize your link survey experience and strengthen your brand presence.",
"customize_favicon_with_higher_plan": "Customize favicon with a higher plan",
"description": "Overview of all surveys using Pretty URLs across your organization",
"favicon_customization": "Favicon",
"favicon_customization_description": "Customize the favicon displayed in browser tabs for your link surveys. This won't affect the admin app.",
"favicon_for_link_surveys": "Favicon for Link Surveys",
"favicon_removed_successfully": "Favicon removed successfully",
"favicon_saved_successfully": "Favicon saved successfully",
"favicon_size_hint": "Max 100KB, square images work best",
"favicon_too_large": "Favicon must be under 100KB",
"no_pretty_urls": "No surveys with Pretty URLs configured yet.",
"pretty_url": "Pretty URL",
"project": "Project",

View File

@@ -349,6 +349,7 @@
"remove": "Eliminar",
"remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"replace": "Reemplazar",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
"request_trial_license": "Solicitar licencia de prueba",
@@ -1053,7 +1054,16 @@
"website_surveys": "Encuestas de sitio web"
},
"domain": {
"customize_favicon_description": "Sube un favicon personalizado para personalizar la experiencia de tu encuesta por enlace y fortalecer la presencia de tu marca.",
"customize_favicon_with_higher_plan": "Personaliza el favicon con un plan superior",
"description": "Resumen de todas las encuestas que utilizan URL bonitas en tu organización",
"favicon_customization": "Favicon",
"favicon_customization_description": "Personaliza el favicon que se muestra en las pestañas del navegador para tus encuestas por enlace. Esto no afectará a la aplicación de administración.",
"favicon_for_link_surveys": "Favicon para encuestas por enlace",
"favicon_removed_successfully": "Favicon eliminado correctamente",
"favicon_saved_successfully": "Favicon guardado correctamente",
"favicon_size_hint": "Máximo 100 KB, las imágenes cuadradas funcionan mejor",
"favicon_too_large": "El favicon debe ser inferior a 100 KB",
"no_pretty_urls": "Aún no hay encuestas con URL bonitas configuradas.",
"pretty_url": "URL bonita",
"project": "Proyecto",

View File

@@ -349,6 +349,7 @@
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"replace": "Remplacer",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
"request_trial_license": "Demander une licence d'essai",
@@ -1053,7 +1054,16 @@
"website_surveys": "Sondages de site web"
},
"domain": {
"customize_favicon_description": "Chargez un favicon personnalisé pour personnaliser l'expérience de vos enquêtes par lien et renforcer la présence de votre marque.",
"customize_favicon_with_higher_plan": "Personnaliser le favicon avec un forfait supérieur",
"description": "Aperçu de toutes les enquêtes utilisant des URL personnalisées dans votre organisation",
"favicon_customization": "Favicon",
"favicon_customization_description": "Personnalisez le favicon affiché dans les onglets du navigateur pour vos enquêtes par lien. Cela n'affectera pas l'application d'administration.",
"favicon_for_link_surveys": "Favicon pour les enquêtes par lien",
"favicon_removed_successfully": "Favicon supprimé avec succès",
"favicon_saved_successfully": "Favicon enregistré avec succès",
"favicon_size_hint": "Max 100 Ko, les images carrées fonctionnent mieux",
"favicon_too_large": "Le favicon doit faire moins de 100 Ko",
"no_pretty_urls": "Aucune enquête avec URL personnalisée configurée pour le moment.",
"pretty_url": "URL personnalisée",
"project": "Projet",

View File

@@ -349,6 +349,7 @@
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
"replace": "置き換え",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
"request_trial_license": "トライアルライセンスをリクエスト",
@@ -1053,7 +1054,16 @@
"website_surveys": "ウェブサイトフォーム"
},
"domain": {
"customize_favicon_description": "カスタムファビコンをアップロードして、リンク調査の体験をパーソナライズし、ブランドプレゼンスを強化します。",
"customize_favicon_with_higher_plan": "上位プランでファビコンをカスタマイズ",
"description": "組織全体でカスタムURLを使用しているすべてのフォームの概要",
"favicon_customization": "ファビコン",
"favicon_customization_description": "リンク調査のブラウザタブに表示されるファビコンをカスタマイズします。管理アプリには影響しません。",
"favicon_for_link_surveys": "リンク調査用ファビコン",
"favicon_removed_successfully": "ファビコンを削除しました",
"favicon_saved_successfully": "ファビコンを保存しました",
"favicon_size_hint": "最大100KB、正方形の画像が最適です",
"favicon_too_large": "ファビコンは100KB以下にする必要があります",
"no_pretty_urls": "カスタムURLが設定されたフォームはまだありません。",
"pretty_url": "カスタムURL",
"project": "プロジェクト",

View File

@@ -349,6 +349,7 @@
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"replace": "Vervangen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
"request_trial_license": "Proeflicentie aanvragen",
@@ -1053,7 +1054,16 @@
"website_surveys": "Website-enquêtes"
},
"domain": {
"customize_favicon_description": "Upload een aangepaste favicon om je linkenquête-ervaring te personaliseren en je merkpresentie te versterken.",
"customize_favicon_with_higher_plan": "Pas favicon aan met een hoger abonnement",
"description": "Overzicht van alle enquêtes die Pretty URL's gebruiken binnen je organisatie",
"favicon_customization": "Favicon",
"favicon_customization_description": "Pas de favicon aan die wordt weergegeven in browsertabbladen voor je linkenquêtes. Dit heeft geen invloed op de beheer-app.",
"favicon_for_link_surveys": "Favicon voor linkenquêtes",
"favicon_removed_successfully": "Favicon succesvol verwijderd",
"favicon_saved_successfully": "Favicon succesvol opgeslagen",
"favicon_size_hint": "Max. 100 KB, vierkante afbeeldingen werken het beste",
"favicon_too_large": "Favicon moet kleiner zijn dan 100 KB",
"no_pretty_urls": "Nog geen enquêtes met Pretty URL's geconfigureerd.",
"pretty_url": "Pretty URL",
"project": "Project",

View File

@@ -349,6 +349,7 @@
"remove": "remover",
"remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"replace": "Substituir",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste",
@@ -1053,7 +1054,16 @@
"website_surveys": "Pesquisas de Site"
},
"domain": {
"customize_favicon_description": "Faça o upload de um favicon personalizado para personalizar a experiência da sua pesquisa por link e fortalecer a presença da sua marca.",
"customize_favicon_with_higher_plan": "Personalize o favicon com um plano superior",
"description": "Visão geral de todas as pesquisas usando URLs amigáveis em sua organização",
"favicon_customization": "Favicon",
"favicon_customization_description": "Personalize o favicon exibido nas abas do navegador para suas pesquisas por link. Isso não afetará o aplicativo administrativo.",
"favicon_for_link_surveys": "Favicon para pesquisas por link",
"favicon_removed_successfully": "Favicon removido com sucesso",
"favicon_saved_successfully": "Favicon salvo com sucesso",
"favicon_size_hint": "Máximo de 100KB, imagens quadradas funcionam melhor",
"favicon_too_large": "O favicon deve ter menos de 100KB",
"no_pretty_urls": "Nenhuma pesquisa com URLs amigáveis configuradas ainda.",
"pretty_url": "URL amigável",
"project": "Projeto",

View File

@@ -349,6 +349,7 @@
"remove": "Remover",
"remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"replace": "Substituir",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste",
@@ -1053,7 +1054,16 @@
"website_surveys": "Inquéritos (site)"
},
"domain": {
"customize_favicon_description": "Carregue um favicon personalizado para personalizar a experiência do seu inquérito por link e reforçar a presença da sua marca.",
"customize_favicon_with_higher_plan": "Personalize o favicon com um plano superior",
"description": "Visão geral de todos os inquéritos que utilizam URLs amigáveis na sua organização",
"favicon_customization": "Favicon",
"favicon_customization_description": "Personalize o favicon apresentado nos separadores do navegador para os seus inquéritos por link. Isto não afetará a aplicação de administração.",
"favicon_for_link_surveys": "Favicon para inquéritos por link",
"favicon_removed_successfully": "Favicon removido com sucesso",
"favicon_saved_successfully": "Favicon guardado com sucesso",
"favicon_size_hint": "Máx. 100KB, imagens quadradas funcionam melhor",
"favicon_too_large": "O favicon deve ter menos de 100KB",
"no_pretty_urls": "Ainda não existem inquéritos com URLs amigáveis configurados.",
"pretty_url": "URL amigável",
"project": "Projeto",

View File

@@ -349,6 +349,7 @@
"remove": "Șterge",
"remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"replace": "Înlocuiește",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
"request_trial_license": "Solicitați o licență de încercare",
@@ -1053,7 +1054,16 @@
"website_surveys": "Sondaje ale site-ului"
},
"domain": {
"customize_favicon_description": "Încărcați un favicon personalizat pentru a oferi o experiență unică sondajului de linkuri și pentru a consolida prezența brandului.",
"customize_favicon_with_higher_plan": "Personalizați faviconul cu un plan superior",
"description": "Prezentare generală a tuturor chestionarelor care folosesc Pretty URLs în organizația dvs.",
"favicon_customization": "Favicon",
"favicon_customization_description": "Personalizați faviconul afișat în filele browserului pentru sondajele de linkuri. Acest lucru nu va afecta aplicația de administrare.",
"favicon_for_link_surveys": "Favicon pentru sondaje de linkuri",
"favicon_removed_successfully": "Faviconul a fost eliminat cu succes",
"favicon_saved_successfully": "Faviconul a fost salvat cu succes",
"favicon_size_hint": "Maxim 100KB, imaginile pătrate se afișează cel mai bine",
"favicon_too_large": "Faviconul trebuie să aibă sub 100KB",
"no_pretty_urls": "Nu există încă chestionare cu Pretty URLs configurate.",
"pretty_url": "Pretty URL",
"project": "Proiect",

View File

@@ -349,6 +349,7 @@
"remove": "Удалить",
"remove_from_team": "Удалить из команды",
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
"replace": "Заменить",
"report_survey": "Пожаловаться на опрос",
"request_pricing": "Запросить стоимость",
"request_trial_license": "Запросить пробную лицензию",
@@ -1053,7 +1054,16 @@
"website_surveys": "Опросы на сайте"
},
"domain": {
"customize_favicon_description": "Загрузите свой favicon, чтобы персонализировать опросы по ссылке и усилить узнаваемость бренда.",
"customize_favicon_with_higher_plan": "Настройка favicon доступна на более продвинутом тарифе",
"description": "Обзор всех опросов с Pretty URL в вашей организации",
"favicon_customization": "Favicon",
"favicon_customization_description": "Настройте favicon, который отображается на вкладках браузера для ваших опросов по ссылке. Это не повлияет на административное приложение.",
"favicon_for_link_surveys": "Favicon для опросов по ссылке",
"favicon_removed_successfully": "Favicon успешно удалён",
"favicon_saved_successfully": "Favicon успешно сохранён",
"favicon_size_hint": "Максимум 100 КБ, лучше всего подходят квадратные изображения",
"favicon_too_large": "Размер favicon должен быть не более 100 КБ",
"no_pretty_urls": "Пока не настроено ни одного опроса с Pretty URL.",
"pretty_url": "Pretty URL",
"project": "Проект",

View File

@@ -349,6 +349,7 @@
"remove": "Ta bort",
"remove_from_team": "Ta bort från teamet",
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
"replace": "Ersätt",
"report_survey": "Rapportera enkät",
"request_pricing": "Begär prissättning",
"request_trial_license": "Begär provlicens",
@@ -1053,7 +1054,16 @@
"website_surveys": "Webbplatsenkäter"
},
"domain": {
"customize_favicon_description": "Ladda upp en egen favicon för att anpassa din länkenkät och stärka ditt varumärke.",
"customize_favicon_with_higher_plan": "Anpassa favicon med ett högre abonnemang",
"description": "Översikt över alla enkäter som använder Pretty URLs i din organisation",
"favicon_customization": "Favicon",
"favicon_customization_description": "Anpassa faviconen som visas i webbläsarflikar för dina länkenkäter. Detta påverkar inte admin-appen.",
"favicon_for_link_surveys": "Favicon för länkenkäter",
"favicon_removed_successfully": "Favicon har tagits bort",
"favicon_saved_successfully": "Favicon har sparats",
"favicon_size_hint": "Max 100 KB, fyrkantiga bilder fungerar bäst",
"favicon_too_large": "Favicon får inte vara större än 100 KB",
"no_pretty_urls": "Inga enkäter med Pretty URLs har konfigurerats ännu.",
"pretty_url": "Pretty URL",
"project": "Projekt",

View File

@@ -349,6 +349,7 @@
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
"replace": "替换",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
"request_trial_license": "申请试用许可证",
@@ -1053,7 +1054,16 @@
"website_surveys": "网站 调查"
},
"domain": {
"customize_favicon_description": "上传自定义 Favicon个性化您的链接问卷体验提升品牌形象。",
"customize_favicon_with_higher_plan": "升级套餐以自定义 Favicon",
"description": "概览组织内所有使用美化 URL 的调查问卷",
"favicon_customization": "Favicon",
"favicon_customization_description": "自定义在浏览器标签页中显示的链接问卷 Favicon。此操作不会影响管理端应用。",
"favicon_for_link_surveys": "链接问卷 Favicon",
"favicon_removed_successfully": "Favicon 移除成功",
"favicon_saved_successfully": "Favicon 保存成功",
"favicon_size_hint": "最大 100KB建议使用方形图片",
"favicon_too_large": "Favicon 必须小于 100KB",
"no_pretty_urls": "尚未配置任何美化 URL 的调查问卷。",
"pretty_url": "美化 URL",
"project": "项目",

View File

@@ -349,6 +349,7 @@
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"replace": "取代",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
@@ -1053,7 +1054,16 @@
"website_surveys": "網站問卷"
},
"domain": {
"customize_favicon_description": "上傳自訂 Favicon讓您的連結問卷體驗更具個人化並強化品牌形象。",
"customize_favicon_with_higher_plan": "升級方案以自訂 Favicon",
"description": "檢視貴組織內所有使用 Pretty URL 的問卷",
"favicon_customization": "Favicon",
"favicon_customization_description": "自訂顯示於瀏覽器分頁上的連結問卷 Favicon。這不會影響管理後台應用程式。",
"favicon_for_link_surveys": "連結問卷 Favicon",
"favicon_removed_successfully": "Favicon 已成功移除",
"favicon_saved_successfully": "Favicon 已成功儲存",
"favicon_size_hint": "最大 100KB建議使用正方形圖片",
"favicon_too_large": "Favicon 必須小於 100KB",
"no_pretty_urls": "尚未設定任何使用 Pretty URL 的問卷。",
"pretty_url": "Pretty URL",
"project": "專案",

View File

@@ -0,0 +1,89 @@
"use server";
import { z } from "zod";
import { ZId, ZUrl } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkWhiteLabelPermission } from "@/modules/ee/whitelabel/email-customization/actions";
import {
removeOrganizationFaviconUrl,
updateOrganizationFaviconUrl,
} from "@/modules/ee/whitelabel/favicon-customization/lib/organization";
const ZUpdateOrganizationFaviconUrlAction = z.object({
organizationId: ZId,
faviconUrl: ZUrl,
});
export const updateOrganizationFaviconUrlAction = authenticatedActionClient
.schema(ZUpdateOrganizationFaviconUrlAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationFaviconUrlAction>;
}) => {
const { organizationId, faviconUrl } = parsedInput;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkWhiteLabelPermission(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = { faviconUrl };
return await updateOrganizationFaviconUrl(organizationId, faviconUrl);
}
)
);
const ZRemoveOrganizationFaviconUrlAction = z.object({
organizationId: ZId,
});
export const removeOrganizationFaviconUrlAction = authenticatedActionClient
.schema(ZRemoveOrganizationFaviconUrlAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRemoveOrganizationFaviconUrlAction>;
}) => {
const { organizationId } = parsedInput;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", roles: ["owner", "manager"] }],
});
await checkWhiteLabelPermission(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = { faviconUrl: "" };
return await removeOrganizationFaviconUrl(organizationId);
}
)
);

View File

@@ -0,0 +1,281 @@
"use client";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
removeOrganizationFaviconUrlAction,
updateOrganizationFaviconUrlAction,
} from "@/modules/ee/whitelabel/favicon-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Muted, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
// Favicon recommended formats - PNG and ICO are most widely supported
const allowedFileExtensions: TAllowedFileExtension[] = ["png", "jpeg", "jpg", "ico", "webp"];
// Maximum favicon size: 512x512 for high-DPI displays
// File size limit: 100KB (realistically favicons should be much smaller)
const MAX_FAVICON_SIZE_MB = 0.1; // 100KB
interface FaviconCustomizationSettingsProps {
organization: TOrganization;
hasWhiteLabelPermission: boolean;
environmentId: string;
isReadOnly: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
}
export const FaviconCustomizationSettings = ({
organization,
hasWhiteLabelPermission,
environmentId,
isReadOnly,
isFormbricksCloud,
isStorageConfigured,
}: FaviconCustomizationSettingsProps) => {
const { t } = useTranslation();
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [faviconUrl, setFaviconUrl] = useState<string>(organization.whitelabel?.faviconUrl || "");
const [isSaving, setIsSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const onFileInputChange = (files: File[]) => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const file = files[0];
if (!file) return;
// Validate file size
const fileSizeInMB = file.size / 1000000;
if (fileSizeInMB > MAX_FAVICON_SIZE_MB) {
toast.error(t("environments.settings.domain.favicon_too_large"));
return;
}
// Revoke any previous object URL so we don't leak memory
if (faviconUrl?.startsWith("blob:")) {
URL.revokeObjectURL(faviconUrl);
}
setFaviconFile(file);
setFaviconUrl(URL.createObjectURL(file));
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const files = Array.from(e.dataTransfer.files);
const file = files[0];
if (!file) return;
const extension = file.name.split(".").pop()?.toLowerCase() as TAllowedFileExtension;
if (!allowedFileExtensions.includes(extension)) {
toast.error(t("common.invalid_file_type"));
return;
}
onFileInputChange(files);
};
const removeFavicon = async () => {
if (faviconUrl?.startsWith("blob:")) {
URL.revokeObjectURL(faviconUrl);
}
setFaviconFile(null);
setFaviconUrl("");
if (inputRef.current) {
inputRef.current.value = "";
}
if (!organization.whitelabel?.faviconUrl) return;
const removeFaviconResponse = await removeOrganizationFaviconUrlAction({
organizationId: organization.id,
});
if (removeFaviconResponse?.data) {
toast.success(t("environments.settings.domain.favicon_removed_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(removeFaviconResponse);
toast.error(errorMessage);
}
};
const handleSave = async () => {
if (!faviconFile) return;
setIsSaving(true);
const { url, error } = await handleFileUpload(faviconFile, environmentId, allowedFileExtensions);
if (error) {
toast.error(error);
setIsSaving(false);
return;
}
const updateFaviconResponse = await updateOrganizationFaviconUrlAction({
organizationId: organization.id,
faviconUrl: url,
});
if (updateFaviconResponse?.data) {
toast.success(t("environments.settings.domain.favicon_saved_successfully"));
setFaviconUrl(url);
setFaviconFile(null);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateFaviconResponse);
toast.error(errorMessage);
}
setIsSaving(false);
};
const buttons: [ModalButton, ModalButton] = [
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
];
return (
<SettingsCard
title={t("environments.settings.domain.favicon_customization")}
description={t("environments.settings.domain.favicon_customization_description")}>
{hasWhiteLabelPermission ? (
<div className="flex flex-col gap-4">
<Small>{t("environments.settings.domain.favicon_for_link_surveys")}</Small>
<div className="flex items-center gap-4">
{faviconUrl && (
<div className="flex flex-col gap-2">
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-slate-200 bg-white p-2">
<Image
src={faviconUrl}
alt="Favicon"
className="max-h-full max-w-full object-contain"
width={48}
height={48}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={isReadOnly || isSaving}
onClick={() => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
inputRef.current?.click();
}}>
<RepeatIcon className="h-4 w-4" />
{t("common.replace")}
</Button>
<Button
variant="secondary"
size="sm"
disabled={isReadOnly || isSaving}
onClick={removeFavicon}>
<Trash2Icon className="h-4 w-4" />
{t("common.remove")}
</Button>
</div>
</div>
)}
<Uploader
id="favicon"
name="favicon-file"
handleDragOver={handleDragOver}
uploaderClassName={cn("h-24 w-52", faviconUrl ? "hidden" : "block")}
handleDrop={handleDrop}
allowedFileExtensions={allowedFileExtensions}
multiple={false}
handleUpload={onFileInputChange}
disabled={isReadOnly}
isStorageConfigured={isStorageConfigured}
/>
</div>
<Muted>{t("environments.settings.domain.favicon_size_hint")}</Muted>
{faviconFile && (
<div className="flex gap-2">
<Button onClick={handleSave} loading={isSaving} disabled={isReadOnly}>
{t("common.save")}
</Button>
<Button
variant="ghost"
onClick={() => {
if (faviconUrl.startsWith("blob:")) {
URL.revokeObjectURL(faviconUrl);
}
setFaviconFile(null);
setFaviconUrl(organization.whitelabel?.faviconUrl || "");
}}>
{t("common.cancel")}
</Button>
</div>
)}
{isReadOnly && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
) : (
<UpgradePrompt
title={t("environments.settings.domain.customize_favicon_with_higher_plan")}
description={t("environments.settings.domain.customize_favicon_description")}
buttons={buttons}
/>
)}
</SettingsCard>
);
};

View File

@@ -0,0 +1,177 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { removeOrganizationFaviconUrl, updateOrganizationFaviconUrl } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
describe("favicon organization", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("updateOrganizationFaviconUrl", () => {
test("should update organization favicon URL", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: {
logoUrl: "logo.png",
faviconUrl: "old-favicon.png",
},
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(prisma.organization.update).mockResolvedValue({} as never);
const result = await updateOrganizationFaviconUrl("clg123456789012345678901234", "new-favicon.png");
expect(result).toBe(true);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: { whitelabel: true },
});
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
data: {
whitelabel: {
...mockOrganization.whitelabel,
faviconUrl: "new-favicon.png",
},
},
});
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
await expect(
updateOrganizationFaviconUrl("clg123456789012345678901234", "new-favicon.png")
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: { whitelabel: true },
});
expect(prisma.organization.update).not.toHaveBeenCalled();
});
test("should handle organization with no existing whitelabel", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: null,
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(prisma.organization.update).mockResolvedValue({} as never);
const result = await updateOrganizationFaviconUrl("clg123456789012345678901234", "new-favicon.png");
expect(result).toBe(true);
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
data: {
whitelabel: {
faviconUrl: "new-favicon.png",
},
},
});
});
});
describe("removeOrganizationFaviconUrl", () => {
test("should remove organization favicon URL", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: {
logoUrl: "logo.png",
faviconUrl: "old-favicon.png",
},
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(prisma.organization.update).mockResolvedValue({} as never);
const result = await removeOrganizationFaviconUrl("clg123456789012345678901234");
expect(result).toBe(true);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: { whitelabel: true },
});
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
data: {
whitelabel: {
...mockOrganization.whitelabel,
faviconUrl: null,
},
},
});
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
await expect(removeOrganizationFaviconUrl("clg123456789012345678901234")).rejects.toThrow(
ResourceNotFoundError
);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: { whitelabel: true },
});
expect(prisma.organization.update).not.toHaveBeenCalled();
});
test("should handle organization with no existing whitelabel", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: null,
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(prisma.organization.update).mockResolvedValue({} as never);
const result = await removeOrganizationFaviconUrl("clg123456789012345678901234");
expect(result).toBe(true);
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
data: {
whitelabel: {
faviconUrl: null,
},
},
});
});
test("should throw ResourceNotFoundError when prisma update fails with record not found", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: {
faviconUrl: "old-favicon.png",
},
};
const mockError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: "P2015", // PrismaErrorType.RecordDoesNotExist
clientVersion: "2.0.0",
});
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(prisma.organization.update).mockRejectedValue(mockError);
await expect(removeOrganizationFaviconUrl("clg123456789012345678901234")).rejects.toThrow(
ResourceNotFoundError
);
});
});
});

View File

@@ -0,0 +1,87 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZUrl } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationWhitelabel } from "@formbricks/types/organizations";
import { validateInputs } from "@/lib/utils/validate";
export const updateOrganizationFaviconUrl = async (
organizationId: string,
faviconUrl: string
): Promise<boolean> => {
validateInputs([organizationId, ZId], [faviconUrl, ZUrl]);
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { whitelabel: true },
});
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const existingWhitelabel = (organization.whitelabel ?? {}) as TOrganizationWhitelabel;
await prisma.organization.update({
where: { id: organizationId },
data: {
whitelabel: {
...existingWhitelabel,
faviconUrl,
},
},
});
return true;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("Organization", organizationId);
}
throw error;
}
};
export const removeOrganizationFaviconUrl = async (organizationId: string): Promise<boolean> => {
validateInputs([organizationId, ZId]);
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { whitelabel: true },
});
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const existingWhitelabel = (organization.whitelabel ?? {}) as TOrganizationWhitelabel;
await prisma.organization.update({
where: { id: organizationId },
data: {
whitelabel: {
...existingWhitelabel,
faviconUrl: null,
},
},
});
return true;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("Organization", organizationId);
}
throw error;
}
};

View File

@@ -11,7 +11,7 @@ import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
interface ContactSurveyPageProps {
@@ -39,8 +39,38 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
};
}
const { surveyId } = result.data;
return getBasicSurveyMetadata(surveyId);
} catch (error) {
const { title, description, survey, ogImage } = await getBasicSurveyMetadata(surveyId);
if (!survey) {
return { title, description };
}
// Fetch organization whitelabel data for custom favicon
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
const customFaviconUrl = environmentContext.organizationWhitelabel?.faviconUrl;
// Get OpenGraph metadata
const surveyBrandColor = survey.styling?.brandColor?.light;
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
// Override with the custom image URL
if (baseMetadata.openGraph) {
baseMetadata.openGraph.images = ogImage ?? baseMetadata.openGraph.images;
baseMetadata.openGraph.description = description;
}
if (baseMetadata.twitter) {
baseMetadata.twitter.images = ogImage ?? baseMetadata.twitter.images;
baseMetadata.twitter.description = description;
}
return {
title,
description,
...baseMetadata,
...(customFaviconUrl && { icons: customFaviconUrl }),
};
} catch {
// If the token is invalid, we'll return generic metadata
return {
title: "Survey",

View File

@@ -4,7 +4,7 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TOrganizationBilling, TOrganizationWhitelabel } from "@formbricks/types/organizations";
import { validateInputs } from "@/lib/utils/validate";
/**
@@ -22,6 +22,7 @@ export interface TEnvironmentContextForLinkSurvey {
project: TProjectForLinkSurvey;
organizationId: string;
organizationBilling: TOrganizationBilling;
organizationWhitelabel: TOrganizationWhitelabel | null;
}
/**
@@ -65,6 +66,7 @@ export const getEnvironmentContextForLinkSurvey = reactCache(
select: {
id: true,
billing: true,
whitelabel: true,
},
},
},
@@ -92,6 +94,8 @@ export const getEnvironmentContextForLinkSurvey = reactCache(
},
organizationId: environment.project.organizationId,
organizationBilling: environment.project.organization.billing as TOrganizationBilling,
organizationWhitelabel:
(environment.project.organization.whitelabel as TOrganizationWhitelabel) ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,6 +1,7 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (
@@ -16,6 +17,10 @@ export const getMetadataForLinkSurvey = async (
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode, survey);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Fetch organization whitelabel data for custom favicon
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
const customFaviconUrl = environmentContext.organizationWhitelabel?.faviconUrl;
// Use the shared function for creating the base metadata but override with custom data
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
@@ -36,6 +41,7 @@ export const getMetadataForLinkSurvey = async (
title,
description,
...baseMetadata,
...(customFaviconUrl && { icons: customFaviconUrl }),
alternates: {
canonical: canonicalPath,
},

View File

@@ -35,6 +35,7 @@ export type TOrganizationBilling = z.infer<typeof ZOrganizationBilling>;
export const ZOrganizationWhitelabel = z.object({
logoUrl: z.string().nullable(),
faviconUrl: z.string().url().nullish(),
});
export type TOrganizationWhitelabel = z.infer<typeof ZOrganizationWhitelabel>;

View File

@@ -6,6 +6,7 @@ export const ZAllowedFileExtension = z.enum([
"jpeg",
"jpg",
"webp",
"ico",
"pdf",
"eml",
"doc",
@@ -33,6 +34,7 @@ export const mimeTypes: Record<TAllowedFileExtension, string> = {
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
ico: "image/x-icon",
pdf: "application/pdf",
eml: "message/rfc822",
doc: "application/msword",