mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-02 03:09:46 -06:00
Compare commits
9 Commits
v3.17.1
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9b936f4b | ||
|
|
dceda3f6f2 | ||
|
|
c6241f7e7f | ||
|
|
92f1c2b75a | ||
|
|
4d53291c8a | ||
|
|
14b7a69cea | ||
|
|
a9015b008d | ||
|
|
d19d624c0c | ||
|
|
3edaab6c2b |
99
.github/workflows/build-and-push-ecr.yml
vendored
99
.github/workflows/build-and-push-ecr.yml
vendored
@@ -1,99 +0,0 @@
|
||||
name: Build & Push Docker to ECR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to push (e.g., v3.16.1)"
|
||||
required: true
|
||||
default: "v3.16.1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
ECR_REGION: ${{ vars.ECR_REGION }}
|
||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
||||
DOCKERFILE: apps/web/Dockerfile
|
||||
CONTEXT: .
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate image tag input
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||
echo "❌ Image tag is required (non-empty)."
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#IMAGE_TAG} > 128 )); then
|
||||
echo "❌ Image tag must be at most 128 characters."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
|
||||
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
|
||||
echo "❌ Image tag must not start or end with '.' or '-'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate required variables
|
||||
shell: bash
|
||||
env:
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
ECR_REGION: ${{ env.ECR_REGION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
|
||||
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||
aws-region: ${{ env.ECR_REGION }}
|
||||
|
||||
- name: Log in to Amazon ECR
|
||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
- name: Build and push image (Depot remote builder)
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: ${{ env.CONTEXT }}
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
|
||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SquareStack,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -77,7 +77,6 @@ export const ShareSurveyModal = ({
|
||||
description: string;
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -112,7 +111,6 @@ export const ShareSurveyModal = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.WEBSITE_EMBED,
|
||||
@@ -123,7 +121,6 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.share.embed_on_website.description"),
|
||||
componentType: WebsiteEmbedTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.EMAIL,
|
||||
@@ -134,7 +131,6 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.share.send_email.description"),
|
||||
componentType: EmailTab,
|
||||
componentProps: { surveyId: survey.id, email },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.SOCIAL_MEDIA,
|
||||
@@ -145,7 +141,6 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.share.social_media.description"),
|
||||
componentType: SocialMediaTab,
|
||||
componentProps: { surveyUrl, surveyTitle: survey.name },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.QR_CODE,
|
||||
@@ -156,7 +151,6 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.summary.qr_code_description"),
|
||||
componentType: QRCodeTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.DYNAMIC_POPUP,
|
||||
@@ -183,9 +177,9 @@ export const ShareSurveyModal = ({
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -194,14 +188,9 @@ export const ShareSurveyModal = ({
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
if (survey.type !== "link") {
|
||||
return ShareViaType.APP;
|
||||
}
|
||||
return ShareViaType.ANON_LINKS;
|
||||
}, [survey.type]);
|
||||
|
||||
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
|
||||
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(
|
||||
survey.type === "link" ? ShareViaType.ANON_LINKS : ShareViaType.APP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -209,19 +198,11 @@ export const ShareSurveyModal = ({
|
||||
}
|
||||
}, [open, modalView]);
|
||||
|
||||
// Ensure active tab is not disabled - if it is, switch to default
|
||||
useEffect(() => {
|
||||
const activeTab = linkTabs.find((tab) => tab.id === activeId);
|
||||
if (activeTab?.disabled) {
|
||||
setActiveId(getDefaultActiveId());
|
||||
}
|
||||
}, [activeId, linkTabs, getDefaultActiveId]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
setActiveId(getDefaultActiveId());
|
||||
setActiveId(ShareViaType.ANON_LINKS);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -150,13 +150,13 @@ export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) =>
|
||||
name: "title",
|
||||
label: t("environments.surveys.share.link_settings.link_title"),
|
||||
description: t("environments.surveys.share.link_settings.link_title_description"),
|
||||
placeholder: survey.name,
|
||||
placeholder: t("environments.surveys.share.link_settings.link_title_placeholder"),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: t("environments.surveys.share.link_settings.link_description"),
|
||||
description: t("environments.surveys.share.link_settings.link_description_description"),
|
||||
placeholder: "Please complete this survey.",
|
||||
placeholder: t("environments.surveys.share.link_settings.link_description_placeholder"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ interface ShareViewProps {
|
||||
componentProps: any;
|
||||
title: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
activeId: ShareViaType | ShareSettingsType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
|
||||
@@ -110,13 +109,12 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
tab.id === activeId && !tab.disabled
|
||||
tab.id === activeId
|
||||
? "bg-slate-100 font-medium text-slate-900"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}
|
||||
disabled={tab.disabled}>
|
||||
isActive={tab.id === activeId}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
@@ -138,10 +136,9 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2",
|
||||
tab.id === activeId && !tab.disabled
|
||||
tab.id === activeId
|
||||
? "bg-white text-slate-900 shadow-sm hover:bg-white"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
|
||||
@@ -89,4 +89,94 @@ describe("QuestionFilterComboBox", () => {
|
||||
await userEvent.click(comboBoxOpenerButton);
|
||||
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows text input for URL meta field", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: "example.com",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByDisplayValue("example.com");
|
||||
expect(textInput).toBeInTheDocument();
|
||||
expect(textInput).toHaveAttribute("type", "text");
|
||||
});
|
||||
|
||||
test("text input is disabled when no filter value is selected for URL field", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: undefined,
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByRole("textbox");
|
||||
expect(textInput).toBeDisabled();
|
||||
});
|
||||
|
||||
test("text input calls onChangeFilterComboBoxValue when typing for URL field", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: "",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByRole("textbox");
|
||||
await userEvent.type(textInput, "t");
|
||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith("t");
|
||||
});
|
||||
|
||||
test("shows regular combobox for non-URL meta fields", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "source",
|
||||
filterValue: "Equals",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("shows regular combobox for URL field with non-text operations", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Other",
|
||||
fieldId: "url",
|
||||
filterValue: "Equals",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("text input handles string filter combo box values correctly", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: "test-url",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByDisplayValue("test-url");
|
||||
expect(textInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("text input handles non-string filter combo box values gracefully", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: ["array-value"],
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByRole("textbox");
|
||||
expect(textInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ type QuestionFilterComboBoxProps = {
|
||||
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||
handleRemoveMultiSelect: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
fieldId?: string;
|
||||
};
|
||||
|
||||
export const QuestionFilterComboBox = ({
|
||||
@@ -45,6 +46,7 @@ export const QuestionFilterComboBox = ({
|
||||
type,
|
||||
handleRemoveMultiSelect,
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
@@ -75,6 +77,9 @@ export const QuestionFilterComboBox = ({
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
// Check if this is a URL field with string comparison operations that require text input
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
@@ -161,70 +166,80 @@ export const QuestionFilterComboBox = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||
disabled={disabled || !filterValue}
|
||||
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||
/>
|
||||
) : (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
@@ -94,6 +95,7 @@ const questionIcons = {
|
||||
source: ArrowUpFromDotIcon,
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -138,7 +140,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" ? "uppercase" : "capitalize";
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -246,9 +246,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||
<div
|
||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||
key={`${s.questionType.id}-${i}`}>
|
||||
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
|
||||
<QuestionsComboBox
|
||||
key={`${s.questionType.label}-${i}`}
|
||||
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
|
||||
options={questionComboBoxOptions}
|
||||
selected={s.questionType}
|
||||
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||
@@ -276,6 +276,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
? s?.questionType?.questionType
|
||||
: s?.questionType?.type
|
||||
}
|
||||
fieldId={s?.questionType?.id}
|
||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||
|
||||
@@ -231,6 +231,43 @@ describe("surveys", () => {
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should provide extended filter options for URL meta field", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const meta = {
|
||||
url: ["https://example.com", "https://test.com"],
|
||||
source: ["web", "mobile"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
|
||||
|
||||
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
|
||||
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
|
||||
|
||||
expect(urlFilterOption).toBeDefined();
|
||||
expect(urlFilterOption?.filterOptions).toEqual([
|
||||
"Equals",
|
||||
"Not equals",
|
||||
"Contains",
|
||||
"Does not contain",
|
||||
"Starts with",
|
||||
"Does not start with",
|
||||
"Ends with",
|
||||
"Does not end with",
|
||||
]);
|
||||
|
||||
expect(sourceFilterOption).toBeDefined();
|
||||
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
@@ -717,6 +754,119 @@ describe("surveys", () => {
|
||||
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
|
||||
expect(result.tags?.applied).toContain("Tag 1");
|
||||
});
|
||||
|
||||
test("should format URL meta filters with string operations", () => {
|
||||
const selectedFilter = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.meta?.url).toEqual({ op: "contains", value: "example.com" });
|
||||
});
|
||||
|
||||
test("should format URL meta filters with all supported string operations", () => {
|
||||
const testCases = [
|
||||
{ filterValue: "Equals", expected: { op: "equals", value: "https://example.com" } },
|
||||
{ filterValue: "Not equals", expected: { op: "notEquals", value: "https://example.com" } },
|
||||
{ filterValue: "Contains", expected: { op: "contains", value: "example.com" } },
|
||||
{ filterValue: "Does not contain", expected: { op: "doesNotContain", value: "test.com" } },
|
||||
{ filterValue: "Starts with", expected: { op: "startsWith", value: "https://" } },
|
||||
{ filterValue: "Does not start with", expected: { op: "doesNotStartWith", value: "http://" } },
|
||||
{ filterValue: "Ends with", expected: { op: "endsWith", value: ".com" } },
|
||||
{ filterValue: "Does not end with", expected: { op: "doesNotEndWith", value: ".org" } },
|
||||
];
|
||||
|
||||
testCases.forEach(({ filterValue, expected }) => {
|
||||
const selectedFilter = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue, filterComboBoxValue: expected.value },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
expect(result.meta?.url).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle URL meta filters with empty string values", () => {
|
||||
const selectedFilter = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.meta?.url).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle URL meta filters with whitespace-only values", () => {
|
||||
const selectedFilter = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.meta?.url).toEqual({ op: "contains", value: "" });
|
||||
});
|
||||
|
||||
test("should still handle existing meta filters with array values", () => {
|
||||
const selectedFilter = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "source", id: "source" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.meta?.source).toEqual({ op: "equals", value: "google" });
|
||||
});
|
||||
|
||||
test("should handle mixed URL and traditional meta filters", () => {
|
||||
const selectedFilter = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Meta", label: "source", id: "source" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
|
||||
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodayDate", () => {
|
||||
|
||||
@@ -47,6 +47,18 @@ const filterOptions = {
|
||||
ranking: ["Filled out", "Skipped"],
|
||||
};
|
||||
|
||||
// URL/meta text operators mapping
|
||||
const META_OP_MAP = {
|
||||
Equals: "equals",
|
||||
"Not equals": "notEquals",
|
||||
Contains: "contains",
|
||||
"Does not contain": "doesNotContain",
|
||||
"Starts with": "startsWith",
|
||||
"Does not start with": "doesNotStartWith",
|
||||
"Ends with": "endsWith",
|
||||
"Does not end with": "doesNotEndWith",
|
||||
} as const;
|
||||
|
||||
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
|
||||
export const generateQuestionAndFilterOptions = (
|
||||
survey: TSurvey,
|
||||
@@ -165,7 +177,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
Object.keys(meta).forEach((m) => {
|
||||
questionFilterOptions.push({
|
||||
type: "Meta",
|
||||
filterOptions: ["Equals", "Not equals"],
|
||||
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
|
||||
filterComboBoxOptions: meta[m],
|
||||
id: m,
|
||||
});
|
||||
@@ -481,17 +493,23 @@ export const getFormattedFilters = (
|
||||
if (meta.length) {
|
||||
meta.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.meta) filters.meta = {};
|
||||
if (!filterType.filterComboBoxValue) return;
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.meta[questionType.label ?? ""] = {
|
||||
op: "equals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.meta[questionType.label ?? ""] = {
|
||||
op: "notEquals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
|
||||
// For text input cases (URL filtering)
|
||||
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
|
||||
const value = filterType.filterComboBoxValue.trim();
|
||||
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
|
||||
if (op) {
|
||||
filters.meta[questionType.label ?? ""] = { op, value };
|
||||
}
|
||||
}
|
||||
// For dropdown/select cases (existing metadata fields)
|
||||
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
|
||||
const value = filterType.filterComboBoxValue[0]; // Take first selected value
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.meta[questionType.label ?? ""] = { op: "equals", value };
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.meta[questionType.label ?? ""] = { op: "notEquals", value };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +157,46 @@ describe("Response Utils", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("meta: URL string comparison operations", () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: "contains",
|
||||
criteria: { meta: { url: { op: "contains" as const, value: "example.com" } } },
|
||||
expected: { meta: { path: ["url"], string_contains: "example.com" } },
|
||||
},
|
||||
{
|
||||
name: "doesNotContain",
|
||||
criteria: { meta: { url: { op: "doesNotContain" as const, value: "test.com" } } },
|
||||
expected: { NOT: { meta: { path: ["url"], string_contains: "test.com" } } },
|
||||
},
|
||||
{
|
||||
name: "startsWith",
|
||||
criteria: { meta: { url: { op: "startsWith" as const, value: "https://" } } },
|
||||
expected: { meta: { path: ["url"], string_starts_with: "https://" } },
|
||||
},
|
||||
{
|
||||
name: "doesNotStartWith",
|
||||
criteria: { meta: { url: { op: "doesNotStartWith" as const, value: "http://" } } },
|
||||
expected: { NOT: { meta: { path: ["url"], string_starts_with: "http://" } } },
|
||||
},
|
||||
{
|
||||
name: "endsWith",
|
||||
criteria: { meta: { url: { op: "endsWith" as const, value: ".com" } } },
|
||||
expected: { meta: { path: ["url"], string_ends_with: ".com" } },
|
||||
},
|
||||
{
|
||||
name: "doesNotEndWith",
|
||||
criteria: { meta: { url: { op: "doesNotEndWith" as const, value: ".org" } } },
|
||||
expected: { NOT: { meta: { path: ["url"], string_ends_with: ".org" } } },
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ criteria, expected }) => {
|
||||
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
|
||||
expect(result.AND).toEqual([{ AND: [expected] }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWhereClause – data‐field filter operations", () => {
|
||||
@@ -495,10 +535,98 @@ describe("Response Utils", () => {
|
||||
expect(result.os).toContain("MacOS");
|
||||
});
|
||||
|
||||
test("should extract URL data correctly", () => {
|
||||
const responses = [
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
url: "https://example.com/page1",
|
||||
source: "direct",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
url: "https://test.com/page2?param=value",
|
||||
source: "google",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||
expect(result.url).toEqual([]);
|
||||
expect(result.source).toContain("direct");
|
||||
expect(result.source).toContain("google");
|
||||
});
|
||||
|
||||
test("should handle mixed meta data with URLs", () => {
|
||||
const responses = [
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
userAgent: { browser: "Chrome", device: "desktop" },
|
||||
url: "https://formbricks.com/dashboard",
|
||||
country: "US",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
userAgent: { browser: "Safari", device: "mobile" },
|
||||
url: "https://formbricks.com/surveys/123",
|
||||
country: "UK",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||
expect(result.browser).toContain("Chrome");
|
||||
expect(result.browser).toContain("Safari");
|
||||
expect(result.device).toContain("desktop");
|
||||
expect(result.device).toContain("mobile");
|
||||
expect(result.url).toEqual([]);
|
||||
expect(result.country).toContain("US");
|
||||
expect(result.country).toContain("UK");
|
||||
});
|
||||
|
||||
test("should handle empty responses", () => {
|
||||
const result = getResponseMeta([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("should ignore empty or null URL values", () => {
|
||||
const responses = [
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
url: "",
|
||||
source: "direct",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
url: null as any,
|
||||
source: "newsletter",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
url: "https://valid.com",
|
||||
source: "google",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||
expect(result.url).toEqual([]);
|
||||
expect(result.source).toEqual(expect.arrayContaining(["direct", "newsletter", "google"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponseHiddenFields", () => {
|
||||
|
||||
@@ -234,6 +234,60 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "contains":
|
||||
meta.push({
|
||||
meta: {
|
||||
path: updatedKey,
|
||||
string_contains: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "doesNotContain":
|
||||
meta.push({
|
||||
NOT: {
|
||||
meta: {
|
||||
path: updatedKey,
|
||||
string_contains: val.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "startsWith":
|
||||
meta.push({
|
||||
meta: {
|
||||
path: updatedKey,
|
||||
string_starts_with: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "doesNotStartWith":
|
||||
meta.push({
|
||||
NOT: {
|
||||
meta: {
|
||||
path: updatedKey,
|
||||
string_starts_with: val.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "endsWith":
|
||||
meta.push({
|
||||
meta: {
|
||||
path: updatedKey,
|
||||
string_ends_with: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "doesNotEndWith":
|
||||
meta.push({
|
||||
NOT: {
|
||||
meta: {
|
||||
path: updatedKey,
|
||||
string_ends_with: val.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -726,10 +780,13 @@ export const getResponseMeta = (
|
||||
|
||||
responses.forEach((response) => {
|
||||
Object.entries(response.meta).forEach(([key, value]) => {
|
||||
// skip url
|
||||
if (key === "url") return;
|
||||
|
||||
// Handling nested objects (like userAgent)
|
||||
if (key === "url") {
|
||||
if (!meta[key]) {
|
||||
meta[key] = new Set();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
|
||||
if (typeof nestedValue === "string" && nestedValue) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
addConditionBelow,
|
||||
@@ -109,7 +109,6 @@ describe("surveyLogic", () => {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
segment: null,
|
||||
recaptcha: null,
|
||||
};
|
||||
|
||||
const simpleGroup = (): TConditionGroup => ({
|
||||
@@ -176,8 +175,7 @@ describe("surveyLogic", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = removeCondition(group, "c");
|
||||
expect(result).toBe(true);
|
||||
removeCondition(group, "c");
|
||||
expect(group.conditions).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -435,8 +433,6 @@ describe("surveyLogic", () => {
|
||||
)
|
||||
).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isSet")), "en")).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isNotEmpty")), "en")).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isNotSet")), "en")).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isEmpty")), "en")).toBe(true);
|
||||
expect(
|
||||
evaluateLogic(mockSurvey, { f: "foo" }, vars, group({ ...baseCond("isAnyOf", ["foo", "bar"]) }), "en")
|
||||
@@ -514,8 +510,7 @@ describe("surveyLogic", () => {
|
||||
expect(group.conditions.length).toBe(2);
|
||||
toggleGroupConnector(group, "notfound");
|
||||
expect(group.connector).toBe("and");
|
||||
const result = removeCondition(group, "notfound");
|
||||
expect(result).toBe(false);
|
||||
removeCondition(group, "notfound");
|
||||
expect(group.conditions.length).toBe(2);
|
||||
duplicateCondition(group, "notfound");
|
||||
expect(group.conditions.length).toBe(2);
|
||||
@@ -525,192 +520,6 @@ describe("surveyLogic", () => {
|
||||
expect(group.conditions.length).toBe(2);
|
||||
});
|
||||
|
||||
test("removeCondition returns false when condition not found in nested groups", () => {
|
||||
const nestedGroup: TConditionGroup = {
|
||||
id: "nested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "nestedC1",
|
||||
leftOperand: { type: "hiddenField", value: "nf1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [nestedGroup],
|
||||
};
|
||||
|
||||
const result = removeCondition(group, "nonexistent");
|
||||
expect(result).toBe(false);
|
||||
expect(group.conditions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("removeCondition successfully removes from nested groups and cleans up", () => {
|
||||
const nestedGroup: TConditionGroup = {
|
||||
id: "nested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "nestedC1",
|
||||
leftOperand: { type: "hiddenField", value: "nf1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv1" },
|
||||
},
|
||||
{
|
||||
id: "nestedC2",
|
||||
leftOperand: { type: "hiddenField", value: "nf2" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const otherCondition: TSingleCondition = {
|
||||
id: "otherCondition",
|
||||
leftOperand: { type: "hiddenField", value: "other" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "value" },
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [nestedGroup, otherCondition],
|
||||
};
|
||||
|
||||
const result = removeCondition(group, "nestedC1");
|
||||
expect(result).toBe(true);
|
||||
expect(group.conditions).toHaveLength(2);
|
||||
expect((group.conditions[0] as TConditionGroup).conditions).toHaveLength(1);
|
||||
expect((group.conditions[0] as TConditionGroup).conditions[0].id).toBe("nestedC2");
|
||||
expect(group.conditions[1].id).toBe("otherCondition");
|
||||
});
|
||||
|
||||
test("removeCondition flattens group when nested group has only one condition left", () => {
|
||||
const deeplyNestedGroup: TConditionGroup = {
|
||||
id: "deepNested",
|
||||
connector: "or",
|
||||
conditions: [
|
||||
{
|
||||
id: "deepC1",
|
||||
leftOperand: { type: "hiddenField", value: "df1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "dv1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nestedGroup: TConditionGroup = {
|
||||
id: "nested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "nestedC1",
|
||||
leftOperand: { type: "hiddenField", value: "nf1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv1" },
|
||||
},
|
||||
deeplyNestedGroup,
|
||||
],
|
||||
};
|
||||
|
||||
const otherCondition: TSingleCondition = {
|
||||
id: "otherCondition",
|
||||
leftOperand: { type: "hiddenField", value: "other" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "value" },
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [nestedGroup, otherCondition],
|
||||
};
|
||||
|
||||
// Remove the regular condition, leaving only the deeply nested group in the nested group
|
||||
const result = removeCondition(group, "nestedC1");
|
||||
expect(result).toBe(true);
|
||||
|
||||
// The parent group should still have 2 conditions: the nested group and the other condition
|
||||
expect(group.conditions).toHaveLength(2);
|
||||
// The nested group should still be there but now contain only the deeply nested group
|
||||
expect(group.conditions[0].id).toBe("nested");
|
||||
expect((group.conditions[0] as TConditionGroup).conditions).toHaveLength(1);
|
||||
// The nested group should contain the flattened content from the deeply nested group
|
||||
expect((group.conditions[0] as TConditionGroup).conditions[0].id).toBe("deepC1");
|
||||
expect(group.conditions[1].id).toBe("otherCondition");
|
||||
});
|
||||
|
||||
test("removeCondition removes empty groups after cleanup", () => {
|
||||
const emptyNestedGroup: TConditionGroup = {
|
||||
id: "emptyNested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "toBeRemoved",
|
||||
leftOperand: { type: "hiddenField", value: "f1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "v1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
emptyNestedGroup,
|
||||
{
|
||||
id: "keepThis",
|
||||
leftOperand: { type: "hiddenField", value: "f2" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "v2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Remove the only condition from the nested group
|
||||
const result = removeCondition(group, "toBeRemoved");
|
||||
expect(result).toBe(true);
|
||||
|
||||
// The empty nested group should be removed, leaving only the other condition
|
||||
expect(group.conditions).toHaveLength(1);
|
||||
expect(group.conditions[0].id).toBe("keepThis");
|
||||
});
|
||||
|
||||
test("deleteEmptyGroups with complex nested structure", () => {
|
||||
const deepEmptyGroup: TConditionGroup = { id: "deepEmpty", connector: "and", conditions: [] };
|
||||
const middleGroup: TConditionGroup = {
|
||||
id: "middle",
|
||||
connector: "or",
|
||||
conditions: [deepEmptyGroup],
|
||||
};
|
||||
const topGroup: TConditionGroup = {
|
||||
id: "top",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
middleGroup,
|
||||
{
|
||||
id: "validCondition",
|
||||
leftOperand: { type: "hiddenField", value: "f" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "v" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
deleteEmptyGroups(topGroup);
|
||||
|
||||
// Should remove the nested empty groups and keep only the valid condition
|
||||
expect(topGroup.conditions).toHaveLength(1);
|
||||
expect(topGroup.conditions[0].id).toBe("validCondition");
|
||||
});
|
||||
|
||||
// Additional tests for complete coverage
|
||||
|
||||
test("addConditionBelow with nested group correctly adds condition", () => {
|
||||
|
||||
@@ -94,48 +94,21 @@ export const toggleGroupConnector = (group: TConditionGroup, resourceId: string)
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCondition = (group: TConditionGroup, resourceId: string): boolean => {
|
||||
for (let i = group.conditions.length - 1; i >= 0; i--) {
|
||||
export const removeCondition = (group: TConditionGroup, resourceId: string) => {
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const item = group.conditions[i];
|
||||
|
||||
if (item.id === resourceId) {
|
||||
group.conditions.splice(i, 1);
|
||||
cleanupGroup(group);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConditionGroup(item) && removeCondition(item, resourceId)) {
|
||||
cleanupGroup(group);
|
||||
return true;
|
||||
if (isConditionGroup(item)) {
|
||||
removeCondition(item, resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const cleanupGroup = (group: TConditionGroup) => {
|
||||
// Remove empty condition groups first
|
||||
for (let i = group.conditions.length - 1; i >= 0; i--) {
|
||||
const condition = group.conditions[i];
|
||||
if (isConditionGroup(condition)) {
|
||||
cleanupGroup(condition);
|
||||
|
||||
// Remove if empty after cleanup
|
||||
if (condition.conditions.length === 0) {
|
||||
group.conditions.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten if group has only one condition and it's a condition group
|
||||
if (group.conditions.length === 1 && isConditionGroup(group.conditions[0])) {
|
||||
group.connector = group.conditions[0].connector || "and";
|
||||
group.conditions = group.conditions[0].conditions;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEmptyGroups = (group: TConditionGroup) => {
|
||||
cleanupGroup(group);
|
||||
deleteEmptyGroups(group);
|
||||
};
|
||||
|
||||
export const duplicateCondition = (group: TConditionGroup, resourceId: string) => {
|
||||
@@ -157,6 +130,18 @@ export const duplicateCondition = (group: TConditionGroup, resourceId: string) =
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEmptyGroups = (group: TConditionGroup) => {
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const resource = group.conditions[i];
|
||||
|
||||
if (isConditionGroup(resource) && resource.conditions.length === 0) {
|
||||
group.conditions.splice(i, 1);
|
||||
} else if (isConditionGroup(resource)) {
|
||||
deleteEmptyGroups(resource);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createGroupFromResource = (group: TConditionGroup, resourceId: string) => {
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const item = group.conditions[i];
|
||||
@@ -685,9 +670,8 @@ const performCalculation = (
|
||||
if (typeof val === "number" || typeof val === "string") {
|
||||
if (variable.type === "number" && !isNaN(Number(val))) {
|
||||
operandValue = Number(val);
|
||||
} else {
|
||||
operandValue = val;
|
||||
}
|
||||
operandValue = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "Filter anwenden",
|
||||
"are_you_sure": "Bist Du sicher?",
|
||||
"attributes": "Attribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Zurück",
|
||||
"billing": "Abrechnung",
|
||||
"booked": "Gebucht",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "API-Schlüssel Label",
|
||||
"api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.",
|
||||
"api_key_updated": "API-Schlüssel aktualisiert",
|
||||
"delete_permission": "Berechtigung löschen",
|
||||
"duplicate_access": "Doppelter Projektzugriff nicht erlaubt",
|
||||
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
|
||||
"no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Was passiert, wenn Du das Konto löschst",
|
||||
"avatar_update_failed": "Aktualisierung des Avatars fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"backup_code": "Backup-Code",
|
||||
"change_image": "Bild ändern",
|
||||
"confirm_delete_account": "Lösche dein Konto mit all deinen persönlichen Informationen und Daten",
|
||||
"confirm_delete_my_account": "Konto löschen",
|
||||
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
|
||||
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren",
|
||||
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Dauerhafte Entfernung all deiner persönlichen Informationen und Daten",
|
||||
"personal_information": "Persönliche Informationen",
|
||||
"please_enter_email_to_confirm_account_deletion": "Bitte gib {email} in das folgende Feld ein, um die endgültige Löschung deines Kontos zu bestätigen:",
|
||||
"profile_updated_successfully": "Dein Profil wurde erfolgreich aktualisiert",
|
||||
"remove_image": "Bild entfernen",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
|
||||
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "Zwei-Faktor-Code",
|
||||
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
|
||||
"update_personal_info": "Persönliche Daten aktualisieren",
|
||||
"upload_image": "Bild hochladen",
|
||||
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
|
||||
"you_must_select_a_file": "Du musst eine Datei auswählen."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "Die Meta-Daten werden basierend auf dem `lang` Wert in der URL geladen.",
|
||||
"link_description": "Linkbeschreibung",
|
||||
"link_description_description": "Beschreibung mit 55-200 Zeichen funktionieren am besten.",
|
||||
"link_description_placeholder": "Hilf uns, indem du deine Gedanken teilst.",
|
||||
"link_title": "Linktitel",
|
||||
"link_title_description": "Kurze Titel funktionieren am besten als Meta-Titel.",
|
||||
"link_title_placeholder": "Kundenfeedback-Umfrage",
|
||||
"preview_image": "Vorschaubild",
|
||||
"preview_image_description": "Querformatige Bilder mit kleiner Dateigröße (<4MB) funktionieren am besten.",
|
||||
"title": "Link-Einstellungen"
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "Apply filters",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"attributes": "Attributes",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
"billing": "Billing",
|
||||
"booked": "Booked",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "API Key Label",
|
||||
"api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.",
|
||||
"api_key_updated": "API Key updated",
|
||||
"delete_permission": "Delete permission",
|
||||
"duplicate_access": "Duplicate project access not allowed",
|
||||
"no_api_keys_yet": "You don't have any API keys yet",
|
||||
"no_env_permissions_found": "No environment permissions found",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Account deletion consequences",
|
||||
"avatar_update_failed": "Avatar update failed. Please try again.",
|
||||
"backup_code": "Backup Code",
|
||||
"change_image": "Change image",
|
||||
"confirm_delete_account": "Delete your account with all of your personal information and data",
|
||||
"confirm_delete_my_account": "Delete My Account",
|
||||
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
"lost_access": "Lost access",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organization_identification": "Assist your organization in identifying you on Formbricks",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Permanent removal of all of your personal information and data",
|
||||
"personal_information": "Personal information",
|
||||
"please_enter_email_to_confirm_account_deletion": "Please enter {email} in the following field to confirm the definitive deletion of your account:",
|
||||
"profile_updated_successfully": "Your profile was updated successfully",
|
||||
"remove_image": "Remove image",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
|
||||
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "Two-Factor Code",
|
||||
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
|
||||
"update_personal_info": "Update your personal information",
|
||||
"upload_image": "Upload image",
|
||||
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
|
||||
"warning_cannot_undo": "This cannot be undone"
|
||||
"warning_cannot_undo": "This cannot be undone",
|
||||
"you_must_select_a_file": "You must select a file."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Add members to the team and determine their role.",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "The meta data is loaded based on the `lang` value in the URL.",
|
||||
"link_description": "Link description",
|
||||
"link_description_description": "Descriptions between 55-200 characters perform best.",
|
||||
"link_description_placeholder": "Help us improve by sharing your thoughts.",
|
||||
"link_title": "Link title",
|
||||
"link_title_description": "Short titles perform best as Meta Titles.",
|
||||
"link_title_placeholder": "Customer Feedback Survey",
|
||||
"preview_image": "Preview image",
|
||||
"preview_image_description": "Landscape images with small file sizes (<4MB) perform best.",
|
||||
"title": "Link settings"
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "Appliquer des filtres",
|
||||
"are_you_sure": "Es-tu sûr ?",
|
||||
"attributes": "Attributs",
|
||||
"avatar": "Avatar",
|
||||
"back": "Retour",
|
||||
"billing": "Facturation",
|
||||
"booked": "Réservé",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "Étiquette de clé API",
|
||||
"api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.",
|
||||
"api_key_updated": "Clé API mise à jour",
|
||||
"delete_permission": "Supprimer une permission",
|
||||
"duplicate_access": "L'accès en double au projet n'est pas autorisé",
|
||||
"no_api_keys_yet": "Vous n'avez pas encore de clés API.",
|
||||
"no_env_permissions_found": "Aucune autorisation d'environnement trouvée",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Conséquences de la suppression de compte",
|
||||
"avatar_update_failed": "La mise à jour de l'avatar a échoué. Veuillez réessayer.",
|
||||
"backup_code": "Code de sauvegarde",
|
||||
"change_image": "Changer l'image",
|
||||
"confirm_delete_account": "Supprimez votre compte avec toutes vos informations personnelles et données.",
|
||||
"confirm_delete_my_account": "Supprimer mon compte",
|
||||
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
|
||||
"invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
|
||||
"lost_access": "Accès perdu",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Suppression permanente de toutes vos informations et données personnelles.",
|
||||
"personal_information": "Informations personnelles",
|
||||
"please_enter_email_to_confirm_account_deletion": "Veuillez entrer {email} dans le champ suivant pour confirmer la suppression définitive de votre compte :",
|
||||
"profile_updated_successfully": "Votre profil a été mis à jour avec succès.",
|
||||
"remove_image": "Supprimer l'image",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
|
||||
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "Code à deux facteurs",
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"update_personal_info": "Mettez à jour vos informations personnelles",
|
||||
"upload_image": "Télécharger l'image",
|
||||
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
|
||||
"warning_cannot_undo": "Ceci ne peut pas être annulé"
|
||||
"warning_cannot_undo": "Ceci ne peut pas être annulé",
|
||||
"you_must_select_a_file": "Vous devez sélectionner un fichier."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "Les métadonnées sont chargées en fonction de la valeur « lang » dans l'URL.",
|
||||
"link_description": "Description du lien",
|
||||
"link_description_description": "« Les descriptions entre 55 et 200 caractères donnent les meilleurs résultats. »",
|
||||
"link_description_placeholder": "Aidez-nous à nous améliorer en partageant vos pensées.",
|
||||
"link_title": "Titre du lien",
|
||||
"link_title_description": "Les titres courts fonctionnent mieux comme titres méta.",
|
||||
"link_title_placeholder": "Sondage de Retour Clients",
|
||||
"preview_image": "Aperçu de l'image",
|
||||
"preview_image_description": "Les images en paysage avec de petites tailles de fichier (<4MB) fonctionnent le mieux.",
|
||||
"title": "Paramètres de lien"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Certeza?",
|
||||
"attributes": "atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"billing": "Faturamento",
|
||||
"booked": "Reservado",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "Rótulo da Chave API",
|
||||
"api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.",
|
||||
"api_key_updated": "Chave de API atualizada",
|
||||
"delete_permission": "Remover permissão",
|
||||
"duplicate_access": "Acesso duplicado ao projeto não permitido",
|
||||
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
|
||||
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consequências da exclusão da conta",
|
||||
"avatar_update_failed": "Falha ao atualizar o avatar. Por favor, tente novamente.",
|
||||
"backup_code": "Código de Backup",
|
||||
"change_image": "Mudar imagem",
|
||||
"confirm_delete_account": "Apague sua conta com todas as suas informações pessoais e dados",
|
||||
"confirm_delete_my_account": "Excluir Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude sua organização a te identificar no Formbricks",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
|
||||
"personal_information": "Informações pessoais",
|
||||
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo abaixo para confirmar a exclusão definitiva da sua conta:",
|
||||
"profile_updated_successfully": "Seu perfil foi atualizado com sucesso",
|
||||
"remove_image": "Remover imagem",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
|
||||
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "Código de Dois Fatores",
|
||||
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
|
||||
"update_personal_info": "Atualize suas informações pessoais",
|
||||
"upload_image": "Enviar imagem",
|
||||
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito",
|
||||
"you_must_select_a_file": "Você tem que selecionar um arquivo."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicione membros à equipe e determine sua função.",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "Os metadados são carregados com base no valor `lang` na URL.",
|
||||
"link_description": "Descrição do link",
|
||||
"link_description_description": "\"Descrições entre 55-200 caracteres têm um melhor desempenho.\"",
|
||||
"link_description_placeholder": "Ajude-nos a melhorar compartilhando suas opiniões.",
|
||||
"link_title": "Título do link",
|
||||
"link_title_description": "Títulos curtos têm melhor desempenho como Meta Títulos.",
|
||||
"link_title_placeholder": "Pesquisa de Feedback do Cliente",
|
||||
"preview_image": "Imagem de prévia",
|
||||
"preview_image_description": "Imagens em paisagem com tamanhos de arquivo pequenos (<4MB) têm o melhor desempenho.",
|
||||
"title": "Configurações de link"
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"attributes": "Atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"billing": "Faturação",
|
||||
"booked": "Reservado",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "Etiqueta da Chave API",
|
||||
"api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.",
|
||||
"api_key_updated": "Chave API atualizada",
|
||||
"delete_permission": "Eliminar permissão",
|
||||
"duplicate_access": "Acesso duplicado ao projeto não permitido",
|
||||
"no_api_keys_yet": "Ainda não tem nenhuma chave API",
|
||||
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consequências da eliminação da conta",
|
||||
"avatar_update_failed": "Falha na atualização do avatar. Por favor, tente novamente.",
|
||||
"backup_code": "Código de Backup",
|
||||
"change_image": "Alterar imagem",
|
||||
"confirm_delete_account": "Eliminar a sua conta com todas as suas informações e dados pessoais",
|
||||
"confirm_delete_my_account": "Eliminar a Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
|
||||
"invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude a sua organização a identificá-lo no Formbricks",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
|
||||
"personal_information": "Informações pessoais",
|
||||
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo seguinte para confirmar a eliminação definitiva da sua conta:",
|
||||
"profile_updated_successfully": "O seu perfil foi atualizado com sucesso",
|
||||
"remove_image": "Remover imagem",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
|
||||
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "Código de Dois Fatores",
|
||||
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
|
||||
"update_personal_info": "Atualize as suas informações pessoais",
|
||||
"upload_image": "Carregar imagem",
|
||||
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito",
|
||||
"you_must_select_a_file": "Deve selecionar um ficheiro."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "Os metadados são carregados com base no valor `lang` no URL.",
|
||||
"link_description": "Descrição do link",
|
||||
"link_description_description": "Descrições entre 55 a 200 caracteres têm melhor desempenho.",
|
||||
"link_description_placeholder": "Ajude-nos a melhorar compartilhando suas opiniões.",
|
||||
"link_title": "Título do Link",
|
||||
"link_title_description": "Títulos curtos têm melhor desempenho como Meta Titles.",
|
||||
"link_title_placeholder": "Inquérito de Feedback do Cliente",
|
||||
"preview_image": "Pré-visualização da imagem",
|
||||
"preview_image_description": "Imagens de paisagem com tamanhos pequenos (<4MB) apresentam melhor desempenho.",
|
||||
"title": "Definições de ligação"
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "Aplică filtre",
|
||||
"are_you_sure": "Ești sigur?",
|
||||
"attributes": "Atribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Înapoi",
|
||||
"billing": "Facturare",
|
||||
"booked": "Rezervat",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "Etichetă Cheie API",
|
||||
"api_key_security_warning": "Din motive de securitate, cheia API va fi afișată o singură dată după creare. Vă rugăm să o copiați imediat la destinație.",
|
||||
"api_key_updated": "Cheie API actualizată",
|
||||
"delete_permission": "Șterge permisiunea",
|
||||
"duplicate_access": "Accesul dublu la proiect nu este permis",
|
||||
"no_api_keys_yet": "Nu aveți încă chei API",
|
||||
"no_env_permissions_found": "Nu s-au găsit permisiuni pentru mediu",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consecințele ștergerii contului",
|
||||
"avatar_update_failed": "Actualizarea avatarului a eșuat. Vă rugăm să încercați din nou.",
|
||||
"backup_code": "Cod de rezervă",
|
||||
"change_image": "Schimbă imaginea",
|
||||
"confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale",
|
||||
"confirm_delete_my_account": "Șterge Contul Meu",
|
||||
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
|
||||
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
|
||||
"file_size_must_be_less_than_10mb": "Dimensiunea fișierului trebuie să fie mai mică de 10MB.",
|
||||
"invalid_file_type": "Tip de fișier invalid. Sunt permise numai fișiere JPEG, PNG și WEBP.",
|
||||
"lost_access": "Acces pierdut",
|
||||
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
|
||||
"organization_identification": "Ajutați organizația să vă identifice pe Formbricks",
|
||||
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Ștergerea permanentă a tuturor informațiilor și datelor tale personale",
|
||||
"personal_information": "Informații personale",
|
||||
"please_enter_email_to_confirm_account_deletion": "Vă rugăm să introduceți {email} în câmpul următor pentru a confirma ștergerea definitivă a contului dumneavoastră:",
|
||||
"profile_updated_successfully": "Profilul dvs. a fost actualizat cu succes",
|
||||
"remove_image": "Șterge imaginea",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
|
||||
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "Codul cu doi factori",
|
||||
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
|
||||
"update_personal_info": "Actualizează informațiile tale personale",
|
||||
"upload_image": "Încărcați imagine",
|
||||
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată"
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată",
|
||||
"you_must_select_a_file": "Trebuie să selectați un fișier."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "Meta datele sunt încărcate pe baza valorii `lang` din URL.",
|
||||
"link_description": "Descriere legătură",
|
||||
"link_description_description": "Descrierile între 55-200 de caractere au cele mai bune performanțe.",
|
||||
"link_description_placeholder": "Ajutați-ne să ne îmbunătățim împărtășindu-vă gândurile.",
|
||||
"link_title": "Titlu link",
|
||||
"link_title_description": "Titlurile scurte funcționează cel mai bine ca Meta Title-uri.",
|
||||
"link_title_placeholder": "Chestionar de feedback al clienților",
|
||||
"preview_image": "Previzualizare imagine",
|
||||
"preview_image_description": "Imaginile panoramice cu dimensiuni de fișier mici (<4MB) au cel mai bun randament.",
|
||||
"title": "Setări link"
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"apply_filters": "套用篩選器",
|
||||
"are_you_sure": "您確定嗎?",
|
||||
"attributes": "屬性",
|
||||
"avatar": "頭像",
|
||||
"back": "返回",
|
||||
"billing": "帳單",
|
||||
"booked": "已預訂",
|
||||
@@ -747,7 +748,6 @@
|
||||
"api_key_label": "API 金鑰標籤",
|
||||
"api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。",
|
||||
"api_key_updated": "API 金鑰已更新",
|
||||
"delete_permission": "刪除 權限",
|
||||
"duplicate_access": "不允許重複的 project 存取",
|
||||
"no_api_keys_yet": "您還沒有任何 API 金鑰",
|
||||
"no_env_permissions_found": "找不到環境權限",
|
||||
@@ -1111,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "帳戶刪除後果",
|
||||
"avatar_update_failed": "頭像更新失敗。請再試一次。",
|
||||
"backup_code": "備份碼",
|
||||
"change_image": "變更圖片",
|
||||
"confirm_delete_account": "刪除您的帳戶以及您的所有個人資訊和資料",
|
||||
"confirm_delete_my_account": "刪除我的帳戶",
|
||||
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
|
||||
@@ -1122,13 +1124,17 @@
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
|
||||
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
|
||||
"lost_access": "無法存取",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organization_identification": "協助您的組織在 Formbricks 上識別您",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "永久移除您的所有個人資訊和資料",
|
||||
"personal_information": "個人資訊",
|
||||
"please_enter_email_to_confirm_account_deletion": "請在以下欄位中輸入 '{'email'}' 以確認永久刪除您的帳戶:",
|
||||
"profile_updated_successfully": "您的個人資料已成功更新",
|
||||
"remove_image": "移除圖片",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
|
||||
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
|
||||
@@ -1138,8 +1144,10 @@
|
||||
"two_factor_code": "雙重驗證碼",
|
||||
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
|
||||
"update_personal_info": "更新您的個人資訊",
|
||||
"upload_image": "上傳圖片",
|
||||
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
|
||||
"warning_cannot_undo": "此操作無法復原"
|
||||
"warning_cannot_undo": "此操作無法復原",
|
||||
"you_must_select_a_file": "您必須選取檔案。"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "將成員新增至團隊並確定其角色。",
|
||||
@@ -1707,8 +1715,10 @@
|
||||
"language_help_text": "中 繼資料 會 根據 URL 中 的 `lang` 值 載入。",
|
||||
"link_description": "連結描述",
|
||||
"link_description_description": "描述在 55 - 200 個字符之間的表現最好。",
|
||||
"link_description_placeholder": "幫助 我們 改善 , 分享 您 的 想法 。",
|
||||
"link_title": "連結標題",
|
||||
"link_title_description": "短 標題 在 Meta Titles 中表現最佳。",
|
||||
"link_title_placeholder": "顧客 回饋 調查",
|
||||
"preview_image": "預覽 圖片",
|
||||
"preview_image_description": "景觀 圖片 檔案 大小 小於 4MB 效果 最佳。",
|
||||
"title": "連結 設定"
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp", "sendLinkSurveyEmail"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,5 +23,10 @@ export const rateLimitConfigs = {
|
||||
actions: {
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
sendLinkSurveyEmail: {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "action:send-link-survey-email",
|
||||
}, // 10 per hour
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||
import { sendLinkSurveyToVerifiedEmail } from "@/modules/email";
|
||||
import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data";
|
||||
@@ -12,6 +14,14 @@ import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/erro
|
||||
export const sendLinkSurveyEmailAction = actionClient
|
||||
.schema(ZLinkSurveyEmailData)
|
||||
.action(async ({ parsedInput }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.actions.sendLinkSurveyEmail);
|
||||
|
||||
const survey = await getSurveyWithMetadata(parsedInput.surveyId);
|
||||
|
||||
if (!survey.isVerifyEmailEnabled) {
|
||||
throw new InvalidInputError("EMAIL_VERIFICATION_NOT_ENABLED");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("contact-survey page", () => {
|
||||
params: Promise.resolve({ jwt: "token" }),
|
||||
searchParams: Promise.resolve({}),
|
||||
});
|
||||
expect(meta).toEqual({ title: "Survey", description: "Please complete this survey." });
|
||||
expect(meta).toEqual({ title: "Survey", description: "Complete this survey" });
|
||||
});
|
||||
|
||||
test("generateMetadata returns default when verify throws", async () => {
|
||||
@@ -103,7 +103,7 @@ describe("contact-survey page", () => {
|
||||
params: Promise.resolve({ jwt: "token" }),
|
||||
searchParams: Promise.resolve({}),
|
||||
});
|
||||
expect(meta).toEqual({ title: "Survey", description: "Please complete this survey." });
|
||||
expect(meta).toEqual({ title: "Survey", description: "Complete this survey" });
|
||||
});
|
||||
|
||||
test("generateMetadata returns basic metadata when token valid", async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
if (!result.ok) {
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Please complete this survey.",
|
||||
description: "Complete this survey",
|
||||
};
|
||||
}
|
||||
const { surveyId } = result.data;
|
||||
@@ -40,7 +40,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
// If the token is invalid, we'll return generic metadata
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Please complete this survey.",
|
||||
description: "Complete this survey",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("Metadata Utils", () => {
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(result).toEqual({
|
||||
title: "Survey",
|
||||
description: "Please complete this survey.",
|
||||
description: "Complete this survey",
|
||||
survey: null,
|
||||
ogImage: undefined,
|
||||
});
|
||||
@@ -108,9 +108,10 @@ describe("Metadata Utils", () => {
|
||||
const result = await getBasicSurveyMetadata(mockSurveyId);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(result).toEqual({
|
||||
title: "Welcome Headline",
|
||||
description: "Please complete this survey.",
|
||||
title: "Welcome Headline | Test Project",
|
||||
description: "Complete this survey",
|
||||
survey: mockSurvey,
|
||||
ogImage: undefined,
|
||||
});
|
||||
@@ -128,12 +129,13 @@ describe("Metadata Utils", () => {
|
||||
} as TSurvey;
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
|
||||
|
||||
const result = await getBasicSurveyMetadata(mockSurveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: "Test Survey",
|
||||
description: "Please complete this survey.",
|
||||
title: "Test Survey | Test Project",
|
||||
description: "Complete this survey",
|
||||
survey: mockSurvey,
|
||||
ogImage: undefined,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { Metadata } from "next";
|
||||
|
||||
type TBasicSurveyMetadata = {
|
||||
@@ -12,16 +12,22 @@ type TBasicSurveyMetadata = {
|
||||
ogImage?: string;
|
||||
};
|
||||
|
||||
export const getNameForURL = (value: string) => encodeURIComponent(value);
|
||||
/**
|
||||
* Utility function to encode name for URL usage
|
||||
*/
|
||||
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
|
||||
|
||||
export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
|
||||
/**
|
||||
* Utility function to encode brand color for URL usage
|
||||
*/
|
||||
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
|
||||
|
||||
/**
|
||||
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
|
||||
*/
|
||||
export const getBasicSurveyMetadata = async (
|
||||
surveyId: string,
|
||||
languageCode = "default"
|
||||
languageCode?: string
|
||||
): Promise<TBasicSurveyMetadata> => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
@@ -29,7 +35,7 @@ export const getBasicSurveyMetadata = async (
|
||||
if (!survey) {
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Please complete this survey.",
|
||||
description: "Complete this survey",
|
||||
survey: null,
|
||||
ogImage: undefined,
|
||||
};
|
||||
@@ -37,33 +43,38 @@ export const getBasicSurveyMetadata = async (
|
||||
|
||||
const metadata = survey.metadata;
|
||||
const welcomeCard = survey.welcomeCard;
|
||||
const useDefaultLanguageCode =
|
||||
languageCode === "default" ||
|
||||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
|
||||
|
||||
// Determine language code to use for metadata
|
||||
const langCode = useDefaultLanguageCode ? "default" : languageCode;
|
||||
const langCode = languageCode || "default";
|
||||
|
||||
// Set title - priority: custom link metadata > welcome card > survey name
|
||||
const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
|
||||
const titleFromWelcome =
|
||||
welcomeCard?.enabled && welcomeCard.headline
|
||||
? getLocalizedValue(welcomeCard.headline, langCode) || ""
|
||||
: undefined;
|
||||
let title = titleFromMetadata || titleFromWelcome || survey.name;
|
||||
let title = "Survey";
|
||||
if (metadata.title?.[langCode]) {
|
||||
title = metadata.title[langCode];
|
||||
} else if (welcomeCard.enabled && welcomeCard.headline?.default) {
|
||||
title = welcomeCard.headline.default;
|
||||
} else {
|
||||
title = survey.name;
|
||||
}
|
||||
|
||||
// Set description - priority: custom link metadata > welcome card > default
|
||||
const descriptionFromMetadata = metadata?.description
|
||||
? getLocalizedValue(metadata.description, langCode) || ""
|
||||
: undefined;
|
||||
let description = descriptionFromMetadata || "Please complete this survey.";
|
||||
let description = "Complete this survey";
|
||||
if (metadata.description?.[langCode]) {
|
||||
description = metadata.description[langCode];
|
||||
}
|
||||
|
||||
// Get OG image from link metadata if available
|
||||
const { ogImage } = metadata;
|
||||
|
||||
if (!titleFromMetadata) {
|
||||
// Add product name in title if it's Formbricks cloud and not using custom metadata
|
||||
if (!metadata.title?.[langCode]) {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
title = `${title} | Formbricks`;
|
||||
} else {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (project) {
|
||||
title = `${title} | ${project.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +89,10 @@ export const getBasicSurveyMetadata = async (
|
||||
/**
|
||||
* Generate Open Graph metadata for survey
|
||||
*/
|
||||
export const getSurveyOpenGraphMetadata = (
|
||||
surveyId: string,
|
||||
surveyName: string,
|
||||
surveyBrandColor?: string
|
||||
): Metadata => {
|
||||
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => {
|
||||
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color
|
||||
const encodedName = getNameForURL(surveyName);
|
||||
const brandColor = getBrandColorForURL(surveyBrandColor ?? COLOR_DEFAULTS.brandColor);
|
||||
|
||||
const ogImgURL = `/api/v1/client/og?brandColor=${brandColor}&name=${encodedName}`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("./lib/metadata-utils", () => ({
|
||||
describe("getMetadataForLinkSurvey", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockSurveyName = "Test Survey";
|
||||
const mockDescription = "Please complete this survey.";
|
||||
const mockDescription = "Complete this survey";
|
||||
const mockOgImageUrl = "https://example.com/custom-image.png";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -60,7 +60,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
|
||||
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
|
||||
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
|
||||
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: mockSurveyName,
|
||||
|
||||
@@ -15,10 +15,9 @@ export const getMetadataForLinkSurvey = async (
|
||||
|
||||
// Get enhanced metadata that includes custom link metadata
|
||||
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
|
||||
const surveyBrandColor = survey.styling?.brandColor?.light;
|
||||
|
||||
// Use the shared function for creating the base metadata but override with custom data
|
||||
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
|
||||
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title);
|
||||
|
||||
// Override with the custom image URL
|
||||
if (baseMetadata.openGraph) {
|
||||
|
||||
@@ -233,31 +233,12 @@ describe("ConditionsEditor", () => {
|
||||
expect(mockCallbacks.onDuplicateCondition).toHaveBeenCalledWith("cond1");
|
||||
});
|
||||
|
||||
test("calls onCreateGroup from the dropdown menu when enabled", async () => {
|
||||
test("calls onCreateGroup from the dropdown menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConditionsEditor conditions={multipleConditions} config={mockConfig} callbacks={mockCallbacks} />
|
||||
);
|
||||
const createGroupButtons = screen.getAllByText("environments.surveys.edit.create_group");
|
||||
await user.click(createGroupButtons[0]); // Click the first one
|
||||
expect(mockCallbacks.onCreateGroup).toHaveBeenCalledWith("cond1");
|
||||
});
|
||||
|
||||
test("disables the 'Create Group' button when there's only one condition", () => {
|
||||
render(<ConditionsEditor conditions={singleCondition} config={mockConfig} callbacks={mockCallbacks} />);
|
||||
const createGroupButton = screen.getByText("environments.surveys.edit.create_group");
|
||||
expect(createGroupButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables the 'Create Group' button when there are multiple conditions", () => {
|
||||
render(
|
||||
<ConditionsEditor conditions={multipleConditions} config={mockConfig} callbacks={mockCallbacks} />
|
||||
);
|
||||
const createGroupButtons = screen.getAllByText("environments.surveys.edit.create_group");
|
||||
// Both buttons should be enabled since the main group has multiple conditions
|
||||
createGroupButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
await user.click(createGroupButton);
|
||||
expect(mockCallbacks.onCreateGroup).toHaveBeenCalledWith("cond1");
|
||||
});
|
||||
|
||||
test("calls onToggleGroupConnector when the connector is changed", async () => {
|
||||
|
||||
@@ -233,8 +233,7 @@ export function ConditionsEditor({ conditions, config, callbacks, depth = 0 }: C
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => callbacks.onCreateGroup(condition.id)}
|
||||
icon={<WorkflowIcon className="h-4 w-4" />}
|
||||
disabled={conditions.conditions.length <= 1}>
|
||||
icon={<WorkflowIcon className="h-4 w-4" />}>
|
||||
{t("environments.surveys.edit.create_group")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -13,6 +13,8 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
||||
|
||||
**Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com)
|
||||
|
||||
> **Note:** [https://saml.formbricks.com](https://saml.formbricks.com) is hardcoded in Formbricks — do not replace it with your instance URL. It is the fixed SP Entity ID and must match exactly as shown in SAML assertions.
|
||||
|
||||
**Response:** Signed
|
||||
|
||||
**Assertion Signature:** Signed
|
||||
@@ -77,7 +79,7 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
||||
</Step>
|
||||
<Step title="Enter the SAML Integration Settings as shown and click Next">
|
||||
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
|
||||
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com`
|
||||
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com` (hardcoded; do not replace with your instance URL)
|
||||
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
|
||||
</Step>
|
||||
<Step title="Fill the fields mapping as shown and click Next">
|
||||
|
||||
8
infra/.envrc
Normal file
8
infra/.envrc
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This is a better (faster) alternative to the built-in Nix support
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
|
||||
fi
|
||||
|
||||
use flake
|
||||
3
infra/.gitignore
vendored
Normal file
3
infra/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.terraform/
|
||||
builds
|
||||
/.direnv/
|
||||
15
infra/README.md
Normal file
15
infra/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
### Nix Flakes
|
||||
|
||||
This project uses Nix Flakes via direnv.
|
||||
|
||||
Ensure your `~/.config/nix/nix.conf` (or `/etc/nix/nix.conf`) contains:
|
||||
|
||||
```bash
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
If your environment does not support flakes, you can still enter the development shell with:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
61
infra/flake.lock
generated
Normal file
61
infra/flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754767907,
|
||||
"narHash": "sha256-8OnUzRQZkqtUol9vuUuQC30hzpMreKptNyET2T9lB6g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5f08b62ed75415439d48152c2a784e36909b1bc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
46
infra/flake.nix
Normal file
46
infra/flake.nix
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
helm-with-plugins = (
|
||||
pkgs.wrapHelm pkgs.kubernetes-helm {
|
||||
plugins = with pkgs.kubernetes-helmPlugins; [
|
||||
helm-secrets
|
||||
helm-diff
|
||||
helm-s3
|
||||
helm-git
|
||||
];
|
||||
}
|
||||
);
|
||||
helmfile-with-plugins = pkgs.helmfile-wrapped.override {
|
||||
inherit (helm-with-plugins) pluginsDir;
|
||||
};
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
buildInputs = [
|
||||
awscli
|
||||
kubectl
|
||||
helm-with-plugins
|
||||
helmfile-with-plugins
|
||||
terraform
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,9 @@ deployment:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
type: secret
|
||||
db-secrets:
|
||||
nameSuffix: db-secrets
|
||||
type: secret
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: spot
|
||||
reloadOnChange: true
|
||||
@@ -103,6 +106,9 @@ externalSecret:
|
||||
app-secrets:
|
||||
dataFrom:
|
||||
key: stage/formbricks/secrets
|
||||
db-secrets:
|
||||
dataFrom:
|
||||
key: stage/formbricks/terraform/rds/credentials
|
||||
refreshInterval: 1m
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
|
||||
237
infra/terraform/.terraform.lock.hcl
generated
237
infra/terraform/.terraform.lock.hcl
generated
@@ -2,51 +2,71 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.89.0"
|
||||
constraints = ">= 5.46.0, >= 5.79.0, >= 5.83.0"
|
||||
version = "5.100.0"
|
||||
constraints = ">= 3.29.0, >= 4.0.0, >= 4.8.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 4.63.0, >= 5.0.0, >= 5.46.0, >= 5.73.0, >= 5.79.0, >= 5.81.0, >= 5.83.0, >= 5.86.0, >= 5.95.0, < 6.0.0"
|
||||
hashes = [
|
||||
"h1:rFvk42jJEKiSUhK1cbERfNgYm4mD+8tq0ZcxCwpXSJs=",
|
||||
"zh:0e55784d6effc33b9098ffab7fb77a242e0223a59cdcf964caa0be94d14684af",
|
||||
"zh:23c64f3eaeffcafb007c89db3dfca94c8adf06b120af55abddaca55a6c6c924c",
|
||||
"zh:338f620133cb607ce980f1725a0a78f61cbd42f4c601808ec1ee01a6c16c9811",
|
||||
"zh:6ab0499172f17484d7b39924cf06782789df1473d31ebae0c7f3294f6e7a1227",
|
||||
"zh:6dcde3e29e538cdf80971cbdce3b285056fd0e31dd64b02d2dcdf4c02f21d0a9",
|
||||
"zh:75c9b594d77c9125bfb1aaf3fbd77a49e392841d53029b5726eb71d64de1233e",
|
||||
"zh:7b334c23091e7b4c142e378416586292197c40a31a5bdb3b29c4f9afddd286f0",
|
||||
"zh:991bbba72e5eb6eb351f466d68080992f5b0495f862a6723f386d1b4c965aa7d",
|
||||
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9bd2f12eef4a5dceafc211ab3b9a63f0e3e224007a60c1bbb842f76e0377033d",
|
||||
"zh:b1ac1eb3b3e1a79fa5e5ad3364615f23b9ee0b093ceeb809fd386a4d40e7abb4",
|
||||
"zh:cea91f43151b30c428c441b97c3b98bf1e5fb72ef72f6971308e3895e23437f4",
|
||||
"zh:d3f000a1696a43d8186a516aace7d476d1fd76443627980504133477e19c8ecb",
|
||||
"zh:d6f526fbbb3e51b3acc3b9640a158f7acc4a089632fca8ec6db430b450673f25",
|
||||
"zh:e0c542950f96c93e761d50602e449fef8447f1389a6d5242a0a7dc9b06826d0b",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/cloudinit" {
|
||||
version = "2.3.6"
|
||||
version = "2.3.7"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:afnqn3XPnO40laFt+SVHPPKsg1j3HXT0VAO0xBVvmrY=",
|
||||
"zh:1321b5ddede56be3f9b35bf75d7cda79adcb357fad62eb8677b6595e0baaa6cd",
|
||||
"zh:265d66e61b9cd16ca1182ebf094cc0a08fb3687e8193a1dbac6899b16c237151",
|
||||
"zh:3875c3a20e082ac55d5ff24bcaf7133ebc90c7f999fd0fb37cf0f0003474c94c",
|
||||
"zh:68ce41ccd07757c451682703840cae1ec270ed5275cd491bbf8279782dfcbb73",
|
||||
"h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=",
|
||||
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
|
||||
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
|
||||
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
|
||||
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
|
||||
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
|
||||
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
|
||||
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
|
||||
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
|
||||
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
|
||||
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
|
||||
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:8dca3bb3f85ff8ac4d1b3f93975dcb751ed788396c56ebf0c3737ce1a4c60492",
|
||||
"zh:9339bdaa99939291cedf543861353c8e7171ec5231c0dfacaa9bdb3338978dab",
|
||||
"zh:a8510c2256e9a78697910bb5542aeca457c81225ea88130335f6d14a36a36c74",
|
||||
"zh:af7ed71b8fceb60a5e3b7fa663be171e0bd41bb0af30e0e1f06a004c7b584e4a",
|
||||
"zh:bc9de0f921b69d07f5fc1ea65f8af71d8d1a7053aafb500788b30bfce64b8fbe",
|
||||
"zh:bccd0a49f161a91660d7d30dd6b389e6820f29752ccf351f10a3297c96973823",
|
||||
"zh:c69321caca20009abead617f888a67aca990276cb7388b738b19157b88749190",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.3.5"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:FnUk98MI5nOh3VJ16cHf8mchQLewLfN1qZG/MqNgPrI=",
|
||||
"zh:6e89509d056091266532fa64de8c06950010498adf9070bf6ff85bc485a82562",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:86868aec05b58dc0aa1904646a2c26b9367d69b890c9ad70c33c0d3aa7b1485a",
|
||||
"zh:a2ce38fda83a62fa5fb5a70e6ca8453b168575feb3459fa39803f6f40bd42154",
|
||||
"zh:a6c72798f4a9a36d1d1433c0372006cc9b904e8cfd60a2ae03ac5b7d2abd2398",
|
||||
"zh:a8a3141d2fc71c86bf7f3c13b0b3be8a1b0f0144a47572a15af4dfafc051e28a",
|
||||
"zh:aa20a1242eb97445ad26ebcfb9babf2cd675bdb81cac5f989268ebefa4ef278c",
|
||||
"zh:b58a22445fb8804e933dcf835ab06c29a0f33148dce61316814783ee7f4e4332",
|
||||
"zh:cb5626a661ee761e0576defb2a2d75230a3244799d380864f3089c66e99d0dcc",
|
||||
"zh:d1acb00d20445f682c4e705c965e5220530209c95609194c2dc39324f3d4fcce",
|
||||
"zh:d91a254ba77b69a29d8eae8ed0e9367cbf0ea6ac1a85b58e190f8cb096a40871",
|
||||
"zh:f6592327673c9f85cdb6f20336faef240abae7621b834f189c4a62276ea5db41",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/helm" {
|
||||
version = "2.17.0"
|
||||
constraints = "~> 2.17"
|
||||
constraints = ">= 2.9.0, ~> 2.17, < 3.0.0"
|
||||
hashes = [
|
||||
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
|
||||
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
|
||||
@@ -65,100 +85,121 @@ provider "registry.terraform.io/hashicorp/helm" {
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||
version = "2.36.0"
|
||||
constraints = "~> 2.36"
|
||||
version = "2.38.0"
|
||||
constraints = ">= 2.20.0, ~> 2.36"
|
||||
hashes = [
|
||||
"h1:94wlXkBzfXwyLVuJVhMdzK+VGjFnMjdmFkYhQ1RUFhI=",
|
||||
"zh:07f38fcb7578984a3e2c8cf0397c880f6b3eb2a722a120a08a634a607ea495ca",
|
||||
"zh:1adde61769c50dbb799d8bf8bfd5c8c504a37017dfd06c7820f82bcf44ca0d39",
|
||||
"zh:39707f23ab58fd0e686967c0f973c0f5a39c14d6ccfc757f97c345fdd0cd4624",
|
||||
"zh:4cc3dc2b5d06cc22d1c734f7162b0a8fdc61990ff9efb64e59412d65a7ccc92a",
|
||||
"zh:8382dcb82ba7303715b5e67939e07dd1c8ecddbe01d12f39b82b2b7d7357e1d9",
|
||||
"zh:88e8e4f90034186b8bfdea1b8d394621cbc46a064ff2418027e6dba6807d5227",
|
||||
"zh:a6276a75ad170f76d88263fdb5f9558998bf3a3f7650d7bd3387b396410e59f3",
|
||||
"zh:bc816c7e0606e5df98a0c7634b240bb0c8100c3107b8b17b554af702edc6a0c5",
|
||||
"zh:cb2f31d58f37020e840af52755c18afd1f09a833c4903ac59270ab440fab57b7",
|
||||
"zh:ee0d103b8d0089fb1918311683110b4492a9346f0471b136af46d3b019576b22",
|
||||
"h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=",
|
||||
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:f688b9ec761721e401f6859c19c083e3be20a650426f4747cd359cdc079d212a",
|
||||
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.5.3"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:MCzg+hs1/ZQ32u56VzJMWP9ONRQPAAqAjuHuzbyshvI=",
|
||||
"zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927",
|
||||
"zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e",
|
||||
"zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf",
|
||||
"zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d",
|
||||
"zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09",
|
||||
"zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f",
|
||||
"zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1",
|
||||
"zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6",
|
||||
"zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47",
|
||||
"zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.2.3"
|
||||
constraints = ">= 3.0.0"
|
||||
version = "3.2.4"
|
||||
constraints = ">= 2.0.0, >= 3.0.0"
|
||||
hashes = [
|
||||
"h1:I0Um8UkrMUb81Fxq/dxbr3HLP2cecTH2WMJiwKSrwQY=",
|
||||
"zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2",
|
||||
"zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d",
|
||||
"zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3",
|
||||
"zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f",
|
||||
"zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1",
|
||||
"h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=",
|
||||
"zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301",
|
||||
"zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670",
|
||||
"zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed",
|
||||
"zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65",
|
||||
"zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd",
|
||||
"zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5",
|
||||
"zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43",
|
||||
"zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a",
|
||||
"zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991",
|
||||
"zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f",
|
||||
"zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e",
|
||||
"zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615",
|
||||
"zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442",
|
||||
"zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5",
|
||||
"zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f",
|
||||
"zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.7.1"
|
||||
version = "3.7.2"
|
||||
constraints = ">= 2.0.0, >= 3.6.0"
|
||||
hashes = [
|
||||
"h1:t152MY0tQH4a8fLzTtEWx70ITd3azVOrFDn/pQblbto=",
|
||||
"zh:3193b89b43bf5805493e290374cdda5132578de6535f8009547c8b5d7a351585",
|
||||
"zh:3218320de4be943e5812ed3de995946056db86eb8d03aa3f074e0c7316599bef",
|
||||
"zh:419861805a37fa443e7d63b69fb3279926ccf98a79d256c422d5d82f0f387d1d",
|
||||
"zh:4df9bd9d839b8fc11a3b8098a604b9b46e2235eb65ef15f4432bde0e175f9ca6",
|
||||
"zh:5814be3f9c9cc39d2955d6f083bae793050d75c572e70ca11ccceb5517ced6b1",
|
||||
"zh:63c6548a06de1231c8ee5570e42ca09c4b3db336578ded39b938f2156f06dd2e",
|
||||
"zh:697e434c6bdee0502cc3deb098263b8dcd63948e8a96d61722811628dce2eba1",
|
||||
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
|
||||
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
|
||||
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
|
||||
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
|
||||
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
|
||||
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
|
||||
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:a0b8e44927e6327852bbfdc9d408d802569367f1e22a95bcdd7181b1c3b07601",
|
||||
"zh:b7d3af018683ef22794eea9c218bc72d7c35a2b3ede9233b69653b3c782ee436",
|
||||
"zh:d63b911d618a6fe446c65bfc21e793a7663e934b2fef833d42d3ccd38dd8d68d",
|
||||
"zh:fa985cd0b11e6d651f47cff3055f0a9fd085ec190b6dbe99bf5448174434cdea",
|
||||
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
|
||||
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
|
||||
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
|
||||
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
|
||||
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/time" {
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
constraints = ">= 0.9.0"
|
||||
hashes = [
|
||||
"h1:JzYsPugN8Fb7C4NlfLoFu7BBPuRVT2/fCOdCaxshveI=",
|
||||
"zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2",
|
||||
"zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea",
|
||||
"zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511",
|
||||
"zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9",
|
||||
"h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=",
|
||||
"zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74",
|
||||
"zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f",
|
||||
"zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a",
|
||||
"zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38",
|
||||
"zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869",
|
||||
"zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e",
|
||||
"zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625",
|
||||
"zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136",
|
||||
"zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b",
|
||||
"zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44",
|
||||
"zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328",
|
||||
"zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8",
|
||||
"zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b",
|
||||
"zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0",
|
||||
"zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d",
|
||||
"zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75",
|
||||
"zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.0.6"
|
||||
version = "4.1.0"
|
||||
constraints = ">= 3.0.0"
|
||||
hashes = [
|
||||
"h1:n3M50qfWfRSpQV9Pwcvuse03pEizqrmYEryxKky4so4=",
|
||||
"zh:10de0d8af02f2e578101688fd334da3849f56ea91b0d9bd5b1f7a243417fdda8",
|
||||
"zh:37fc01f8b2bc9d5b055dc3e78bfd1beb7c42cfb776a4c81106e19c8911366297",
|
||||
"zh:4578ca03d1dd0b7f572d96bd03f744be24c726bfd282173d54b100fd221608bb",
|
||||
"zh:6c475491d1250050765a91a493ef330adc24689e8837a0f07da5a0e1269e11c1",
|
||||
"zh:81bde94d53cdababa5b376bbc6947668be4c45ab655de7aa2e8e4736dfd52509",
|
||||
"zh:abdce260840b7b050c4e401d4f75c7a199fafe58a8b213947a258f75ac18b3e8",
|
||||
"zh:b754cebfc5184873840f16a642a7c9ef78c34dc246a8ae29e056c79939963c7a",
|
||||
"zh:c928b66086078f9917aef0eec15982f2e337914c5c4dbc31dd4741403db7eb18",
|
||||
"zh:cded27bee5f24de6f2ee0cfd1df46a7f88e84aaffc2ecbf3ff7094160f193d50",
|
||||
"zh:d65eb3867e8f69aaf1b8bb53bd637c99c6b649ba3db16ded50fa9a01076d1a27",
|
||||
"zh:ecb0c8b528c7a619fa71852bb3fb5c151d47576c5aab2bf3af4db52588722eeb",
|
||||
"h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
|
||||
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
|
||||
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
|
||||
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
|
||||
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
|
||||
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
|
||||
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
|
||||
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
|
||||
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
|
||||
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
|
||||
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
|
||||
]
|
||||
}
|
||||
|
||||
121
infra/terraform/db_users/cloudwatch.tf
Normal file
121
infra/terraform/db_users/cloudwatch.tf
Normal file
@@ -0,0 +1,121 @@
|
||||
resource "aws_sns_topic" "this" {
|
||||
name = "lambda-metrics-alarm"
|
||||
}
|
||||
|
||||
module "alarm" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "~> 3.0"
|
||||
|
||||
alarm_name = "lambda-duration-lbda-rotate-db-secret"
|
||||
alarm_description = "Lambda duration is too high"
|
||||
comparison_operator = "GreaterThanOrEqualToThreshold"
|
||||
evaluation_periods = 1
|
||||
threshold = 10
|
||||
period = 60
|
||||
unit = "Milliseconds"
|
||||
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Duration"
|
||||
statistic = "Maximum"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.this.arn]
|
||||
}
|
||||
|
||||
module "alarm_metric_query" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "~> 3.0"
|
||||
|
||||
alarm_name = "mq-lambda-duration-lbda-rotate-db-secret"
|
||||
alarm_description = "Lambda error rate is too high"
|
||||
comparison_operator = "GreaterThanOrEqualToThreshold"
|
||||
evaluation_periods = 1
|
||||
threshold = 10
|
||||
|
||||
metric_query = [{
|
||||
id = "e1"
|
||||
|
||||
return_data = true
|
||||
expression = "m2/m1*100"
|
||||
label = "Error Rate"
|
||||
}, {
|
||||
id = "m1"
|
||||
|
||||
metric = [{
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Invocations"
|
||||
period = 60
|
||||
stat = "Sum"
|
||||
unit = "Count"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
id = "m2"
|
||||
|
||||
metric = [{
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Errors"
|
||||
period = 60
|
||||
stat = "Sum"
|
||||
unit = "Count"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
}]
|
||||
}]
|
||||
|
||||
alarm_actions = [aws_sns_topic.this.arn]
|
||||
|
||||
tags = {
|
||||
Secure = "maybe"
|
||||
}
|
||||
}
|
||||
|
||||
module "alarm_anomaly" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "~> 3.0"
|
||||
|
||||
alarm_name = "lambda-invocations-anomaly-lbda-rotate-db-secret"
|
||||
alarm_description = "Lambda invocations anomaly"
|
||||
comparison_operator = "LessThanLowerOrGreaterThanUpperThreshold"
|
||||
evaluation_periods = 1
|
||||
threshold_metric_id = "ad1"
|
||||
|
||||
metric_query = [{
|
||||
id = "ad1"
|
||||
|
||||
return_data = true
|
||||
expression = "ANOMALY_DETECTION_BAND(m1, 2)"
|
||||
label = "Invocations (expected)"
|
||||
return_data = "true"
|
||||
},
|
||||
{
|
||||
id = "m1"
|
||||
|
||||
metric = [{
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Invocations"
|
||||
period = 60
|
||||
stat = "Sum"
|
||||
unit = "Count"
|
||||
|
||||
dimensions = {
|
||||
FunctionName = module.lambda_rotate_db_secret.lambda_function_name
|
||||
}
|
||||
}]
|
||||
return_data = "true"
|
||||
}]
|
||||
|
||||
alarm_actions = [aws_sns_topic.this.arn]
|
||||
|
||||
tags = {
|
||||
Secure = "maybe"
|
||||
}
|
||||
}
|
||||
20
infra/terraform/db_users/data.tf
Normal file
20
infra/terraform/db_users/data.tf
Normal file
@@ -0,0 +1,20 @@
|
||||
data "aws_region" "selected" {}
|
||||
|
||||
data "aws_secretsmanager_secret" "rds_credentials" {
|
||||
arn = data.terraform_remote_state.main.outputs.rds_secret_staging_arn
|
||||
}
|
||||
|
||||
# Default KMS key for Secrets Manager
|
||||
data "aws_kms_key" "secretsmanager" {
|
||||
key_id = "alias/aws/secretsmanager"
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "main" {
|
||||
backend = "s3"
|
||||
|
||||
config = {
|
||||
bucket = "715841356175-terraform"
|
||||
key = "terraform.tfstate"
|
||||
region = "eu-central-1"
|
||||
}
|
||||
}
|
||||
71
infra/terraform/db_users/lambda.tf
Normal file
71
infra/terraform/db_users/lambda.tf
Normal file
@@ -0,0 +1,71 @@
|
||||
resource "aws_lambda_layer_version" "psycopg2_layer" {
|
||||
layer_name = "psycopg2-layer"
|
||||
description = "Psycopg2 PostgreSQL driver for AWS Lambda"
|
||||
compatible_runtimes = ["python3.9"]
|
||||
filename = "./lambda/deps/psycopg2-layer.zip"
|
||||
}
|
||||
|
||||
module "lambda_rotate_db_secret" {
|
||||
source = "terraform-aws-modules/lambda/aws"
|
||||
version = "7.20.1"
|
||||
|
||||
function_name = "lbda-rotate-db-secret"
|
||||
description = "Rotate Aurora Serverless PostgreSQL DB secret"
|
||||
handler = "lambda_function.lambda_handler"
|
||||
source_path = "./lambda/src/lambda_function.py"
|
||||
create_package = true
|
||||
package_type = "Zip"
|
||||
runtime = "python3.9"
|
||||
timeout = 30
|
||||
memory_size = 128
|
||||
layers = [aws_lambda_layer_version.psycopg2_layer.arn]
|
||||
create_role = true
|
||||
role_name = "iamr-lbda-rotate-db-secret-role"
|
||||
policy_name = "iamp-lbda-rotate-db-secret-policy"
|
||||
attach_policy_json = true
|
||||
policy_json = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = [
|
||||
"kms:GenerateDataKey",
|
||||
"kms:Encrypt",
|
||||
"kms:DescribeKey",
|
||||
"kms:Decrypt"
|
||||
]
|
||||
Effect = "Allow"
|
||||
Resource = "*"
|
||||
Sid = "AllowKMS"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"secretsmanager:UpdateSecretVersionStage",
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret"
|
||||
]
|
||||
Effect = "Allow"
|
||||
Resource = "*"
|
||||
Sid = "AllowSecretsManager"
|
||||
},
|
||||
{
|
||||
Action = "secretsmanager:GetRandomPassword"
|
||||
Effect = "Allow"
|
||||
Resource = "*"
|
||||
Sid = "AllowSecretsManagerRandomPassword"
|
||||
}
|
||||
]
|
||||
})
|
||||
tags = {
|
||||
Environment = "dev"
|
||||
Project = "aurora-serverless"
|
||||
Zone = "db-zone"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "AllowSecretsManager" {
|
||||
statement_id = "AllowSecretsManager"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = module.lambda_rotate_db_secret.lambda_function_name
|
||||
principal = "secretsmanager.amazonaws.com"
|
||||
}
|
||||
589
infra/terraform/db_users/lambda/src/lambda_function.py
Normal file
589
infra/terraform/db_users/lambda/src/lambda_function.py
Normal file
@@ -0,0 +1,589 @@
|
||||
# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: MIT-0
|
||||
# https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/master/SecretsManagerRDSPostgreSQLRotationSingleUser/lambda_function.py
|
||||
# Updated this function library from pg, pgdb to psycopg2 to support python3.9
|
||||
|
||||
import re
|
||||
import boto3
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""Secrets Manager RDS PostgreSQL Handler
|
||||
|
||||
This handler uses the single-user rotation scheme to rotate an RDS PostgreSQL user credential. This rotation
|
||||
scheme logs into the database as the user and rotates the user's own password, immediately invalidating the
|
||||
user's previous password.
|
||||
|
||||
The Secret SecretString is expected to be a JSON string with the following format:
|
||||
{
|
||||
'engine': <required: must be set to 'postgres'>,
|
||||
'host': <required: instance host name>,
|
||||
'username': <required: username>,
|
||||
'password': <required: password>,
|
||||
'dbname': <optional: database name, default to 'postgres'>,
|
||||
'port': <optional: if not specified, default port 5432 will be used>
|
||||
}
|
||||
|
||||
Args:
|
||||
event (dict): Lambda dictionary of event parameters. These keys must include the following:
|
||||
- SecretId: The secret ARN or identifier
|
||||
- ClientRequestToken: The ClientRequestToken of the secret version
|
||||
- Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret)
|
||||
|
||||
context (LambdaContext): The Lambda runtime information
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not properly configured for rotation
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
arn = event["SecretId"]
|
||||
token = event["ClientRequestToken"]
|
||||
step = event["Step"]
|
||||
|
||||
# Setup the client
|
||||
service_client = boto3.client(
|
||||
"secretsmanager", endpoint_url=os.environ["SECRETS_MANAGER_ENDPOINT"]
|
||||
)
|
||||
|
||||
# Make sure the version is staged correctly
|
||||
metadata = service_client.describe_secret(SecretId=arn)
|
||||
if "RotationEnabled" in metadata and not metadata["RotationEnabled"]:
|
||||
logger.error("Secret %s is not enabled for rotation" % arn)
|
||||
raise ValueError("Secret %s is not enabled for rotation" % arn)
|
||||
versions = metadata["VersionIdsToStages"]
|
||||
if token not in versions:
|
||||
logger.error(
|
||||
"Secret version %s has no stage for rotation of secret %s." % (token, arn)
|
||||
)
|
||||
raise ValueError(
|
||||
"Secret version %s has no stage for rotation of secret %s." % (token, arn)
|
||||
)
|
||||
if "AWSCURRENT" in versions[token]:
|
||||
logger.info(
|
||||
"Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)
|
||||
)
|
||||
return
|
||||
elif "AWSPENDING" not in versions[token]:
|
||||
logger.error(
|
||||
"Secret version %s not set as AWSPENDING for rotation of secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
raise ValueError(
|
||||
"Secret version %s not set as AWSPENDING for rotation of secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
|
||||
# Call the appropriate step
|
||||
if step == "createSecret":
|
||||
create_secret(service_client, arn, token)
|
||||
|
||||
elif step == "setSecret":
|
||||
set_secret(service_client, arn, token)
|
||||
|
||||
elif step == "testSecret":
|
||||
test_secret(service_client, arn, token)
|
||||
|
||||
elif step == "finishSecret":
|
||||
finish_secret(service_client, arn, token)
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)
|
||||
)
|
||||
raise ValueError("Invalid step parameter %s for secret %s" % (step, arn))
|
||||
|
||||
|
||||
def create_secret(service_client, arn, token):
|
||||
"""Generate a new secret
|
||||
|
||||
This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a
|
||||
new secret and put it with the passed in token.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
Raises:
|
||||
ValueError: If the current secret is not valid JSON
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
# Make sure the current secret exists
|
||||
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
|
||||
|
||||
# Now try to get the secret version, if that fails, put a new secret
|
||||
try:
|
||||
get_secret_dict(service_client, arn, "AWSPENDING", token)
|
||||
logger.info("createSecret: Successfully retrieved secret for %s." % arn)
|
||||
except service_client.exceptions.ResourceNotFoundException:
|
||||
# Generate a random password
|
||||
current_dict["password"] = get_random_password(service_client)
|
||||
# Put the secret
|
||||
service_client.put_secret_value(
|
||||
SecretId=arn,
|
||||
ClientRequestToken=token,
|
||||
SecretString=json.dumps(current_dict),
|
||||
VersionStages=["AWSPENDING"],
|
||||
)
|
||||
logger.info(
|
||||
"createSecret: Successfully put secret for ARN %s and version %s."
|
||||
% (arn, token)
|
||||
)
|
||||
|
||||
|
||||
def set_secret(service_client, arn, token):
|
||||
"""Set the pending secret in the database
|
||||
|
||||
This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it
|
||||
tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password
|
||||
as the user password in the database. Else, it throws a ValueError.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not valid JSON or valid credentials are found to login to the database
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
try:
|
||||
previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS")
|
||||
except (service_client.exceptions.ResourceNotFoundException, KeyError):
|
||||
previous_dict = None
|
||||
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
|
||||
pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token)
|
||||
|
||||
# First try to login with the pending secret, if it succeeds, return
|
||||
conn = get_connection(pending_dict)
|
||||
if conn:
|
||||
conn.close()
|
||||
logger.info(
|
||||
"setSecret: AWSPENDING secret is already set as password in PostgreSQL DB for secret arn %s."
|
||||
% arn
|
||||
)
|
||||
return
|
||||
|
||||
# Make sure the user from current and pending match
|
||||
if current_dict["username"] != pending_dict["username"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user %s other than current user %s"
|
||||
% (pending_dict["username"], current_dict["username"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user %s other than current user %s"
|
||||
% (pending_dict["username"], current_dict["username"])
|
||||
)
|
||||
|
||||
# Make sure the host from current and pending match
|
||||
if current_dict["host"] != pending_dict["host"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user for host %s other than current host %s"
|
||||
% (pending_dict["host"], current_dict["host"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user for host %s other than current host %s"
|
||||
% (pending_dict["host"], current_dict["host"])
|
||||
)
|
||||
|
||||
# Now try the current password
|
||||
conn = get_connection(current_dict)
|
||||
|
||||
# If both current and pending do not work, try previous
|
||||
if not conn and previous_dict:
|
||||
# Update previous_dict to leverage current SSL settings
|
||||
previous_dict.pop("ssl", None)
|
||||
if "ssl" in current_dict:
|
||||
previous_dict["ssl"] = current_dict["ssl"]
|
||||
|
||||
conn = get_connection(previous_dict)
|
||||
|
||||
# Make sure the user/host from previous and pending match
|
||||
if previous_dict["username"] != pending_dict["username"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user %s other than previous valid user %s"
|
||||
% (pending_dict["username"], previous_dict["username"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user %s other than previous valid user %s"
|
||||
% (pending_dict["username"], previous_dict["username"])
|
||||
)
|
||||
if previous_dict["host"] != pending_dict["host"]:
|
||||
logger.error(
|
||||
"setSecret: Attempting to modify user for host %s other than previous valid host %s"
|
||||
% (pending_dict["host"], previous_dict["host"])
|
||||
)
|
||||
raise ValueError(
|
||||
"Attempting to modify user for host %s other than current previous valid %s"
|
||||
% (pending_dict["host"], previous_dict["host"])
|
||||
)
|
||||
|
||||
# If we still don't have a connection, raise a ValueError
|
||||
if not conn:
|
||||
logger.error(
|
||||
"setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s"
|
||||
% arn
|
||||
)
|
||||
raise ValueError(
|
||||
"Unable to log into database with previous, current, or pending secret of secret arn %s"
|
||||
% arn
|
||||
)
|
||||
|
||||
# Now set the password to the pending password
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Get escaped username via quote_ident
|
||||
cur.execute("SELECT quote_ident(%s)", (pending_dict["username"],))
|
||||
escaped_username = cur.fetchone()[0]
|
||||
|
||||
alter_role = "ALTER USER %s" % escaped_username
|
||||
cur.execute(alter_role + " WITH PASSWORD %s", (pending_dict["password"],))
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"setSecret: Successfully set password for user %s in PostgreSQL DB for secret arn %s."
|
||||
% (pending_dict["username"], arn)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_secret(service_client, arn, token):
|
||||
"""Test the pending secret against the database
|
||||
|
||||
This method tries to log into the database with the secrets staged with AWSPENDING and runs
|
||||
a permissions check to ensure the user has the corrrect permissions.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not valid JSON or valid credentials are found to login to the database
|
||||
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
# Try to login with the pending secret, if it succeeds, return
|
||||
conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token))
|
||||
if conn:
|
||||
# This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to
|
||||
# tailor these validations to your needs
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT NOW()")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"testSecret: Successfully signed into PostgreSQL DB with AWSPENDING secret in %s."
|
||||
% arn
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error(
|
||||
"testSecret: Unable to log into database with pending secret of secret ARN %s"
|
||||
% arn
|
||||
)
|
||||
raise ValueError(
|
||||
"Unable to log into database with pending secret of secret ARN %s" % arn
|
||||
)
|
||||
|
||||
|
||||
def finish_secret(service_client, arn, token):
|
||||
"""Finish the rotation by marking the pending secret as current
|
||||
|
||||
This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage.
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version
|
||||
|
||||
"""
|
||||
# First describe the secret to get the current version
|
||||
metadata = service_client.describe_secret(SecretId=arn)
|
||||
current_version = None
|
||||
for version in metadata["VersionIdsToStages"]:
|
||||
if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
|
||||
if version == token:
|
||||
# The correct version is already marked as current, return
|
||||
logger.info(
|
||||
"finishSecret: Version %s already marked as AWSCURRENT for %s"
|
||||
% (version, arn)
|
||||
)
|
||||
return
|
||||
current_version = version
|
||||
break
|
||||
|
||||
# Finalize by staging the secret version current
|
||||
service_client.update_secret_version_stage(
|
||||
SecretId=arn,
|
||||
VersionStage="AWSCURRENT",
|
||||
MoveToVersionId=token,
|
||||
RemoveFromVersionId=current_version,
|
||||
)
|
||||
logger.info(
|
||||
"finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
|
||||
|
||||
def get_connection(secret_dict):
|
||||
"""Gets a connection to PostgreSQL DB from a secret dictionary
|
||||
|
||||
This helper function uses connectivity information from the secret dictionary to initiate
|
||||
connection attempt(s) to the database. Will attempt a fallback, non-SSL connection when
|
||||
initial connection fails using SSL and fall_back is True.
|
||||
|
||||
Args:
|
||||
secret_dict (dict): The Secret Dictionary
|
||||
|
||||
Returns:
|
||||
Connection: The psycopg2 connection object if successful. None otherwise
|
||||
|
||||
Raises:
|
||||
KeyError: If the secret json does not contain the expected keys
|
||||
|
||||
"""
|
||||
# Parse and validate the secret JSON string
|
||||
port = int(secret_dict.get("port", 5432))
|
||||
dbname = secret_dict.get("dbname", "postgres")
|
||||
|
||||
# Get SSL connectivity configuration
|
||||
use_ssl, fall_back = get_ssl_config(secret_dict)
|
||||
|
||||
# Attempt initial connection
|
||||
conn = connect_and_authenticate(secret_dict, port, dbname, use_ssl)
|
||||
if conn or not fall_back:
|
||||
return conn
|
||||
|
||||
# Attempt fallback connection without SSL
|
||||
return connect_and_authenticate(secret_dict, port, dbname, False)
|
||||
|
||||
|
||||
def get_ssl_config(secret_dict):
|
||||
"""Gets the desired SSL and fall back behavior using a secret dictionary
|
||||
|
||||
This helper function uses the existance and value the 'ssl' key in a secret dictionary
|
||||
to determine desired SSL connectivity configuration. Its behavior is as follows:
|
||||
- 'ssl' key DNE or invalid type/value: return True, True
|
||||
- 'ssl' key is bool: return secret_dict['ssl'], False
|
||||
- 'ssl' key equals "true" ignoring case: return True, False
|
||||
- 'ssl' key equals "false" ignoring case: return False, False
|
||||
|
||||
Args:
|
||||
secret_dict (dict): The Secret Dictionary
|
||||
|
||||
Returns:
|
||||
Tuple(use_ssl, fall_back): SSL configuration
|
||||
- use_ssl (bool): Flag indicating if an SSL connection should be attempted
|
||||
- fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails
|
||||
|
||||
"""
|
||||
# Default to True for SSL and fall_back mode if 'ssl' key DNE
|
||||
if "ssl" not in secret_dict:
|
||||
return True, True
|
||||
|
||||
# Handle type bool
|
||||
if isinstance(secret_dict["ssl"], bool):
|
||||
return secret_dict["ssl"], False
|
||||
|
||||
# Handle type string
|
||||
if isinstance(secret_dict["ssl"], str):
|
||||
ssl = secret_dict["ssl"].lower()
|
||||
if ssl == "true":
|
||||
return True, False
|
||||
elif ssl == "false":
|
||||
return False, False
|
||||
else:
|
||||
# Invalid string value, default to True for both SSL and fall_back mode
|
||||
return True, True
|
||||
|
||||
# Invalid type, default to True for both SSL and fall_back mode
|
||||
return True, True
|
||||
|
||||
|
||||
def connect_and_authenticate(secret_dict, port, dbname, use_ssl):
|
||||
"""Attempt to connect and authenticate to a PostgreSQL instance using psycopg2
|
||||
|
||||
Args:
|
||||
secret_dict (dict): The Secret Dictionary
|
||||
port (int): The database port to connect to
|
||||
dbname (str): Name of the database
|
||||
use_ssl (bool): Flag indicating whether connection should use SSL/TLS
|
||||
|
||||
Returns:
|
||||
Connection: The psycopg2 connection object if successful. None otherwise
|
||||
"""
|
||||
try:
|
||||
conn_params = {
|
||||
"host": secret_dict["host"],
|
||||
"user": secret_dict["username"],
|
||||
"password": secret_dict["password"],
|
||||
"dbname": dbname,
|
||||
"port": port,
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
|
||||
if use_ssl:
|
||||
conn_params.update(
|
||||
{"sslmode": "verify-full", "sslrootcert": "/etc/pki/tls/cert.pem"}
|
||||
)
|
||||
else:
|
||||
conn_params["sslmode"] = "disable"
|
||||
|
||||
conn = psycopg2.connect(**conn_params)
|
||||
logging.info(
|
||||
"Successfully established %s connection as user '%s' with host: '%s'",
|
||||
"SSL/TLS" if use_ssl else "non SSL/TLS",
|
||||
secret_dict["username"],
|
||||
secret_dict["host"],
|
||||
)
|
||||
return conn
|
||||
except psycopg2.OperationalError as e:
|
||||
error_message = str(e)
|
||||
if "server does not support SSL, but SSL was required" in error_message:
|
||||
logging.error(
|
||||
"Unable to establish SSL/TLS handshake, SSL/TLS is not enabled on the host: %s",
|
||||
secret_dict["host"],
|
||||
)
|
||||
elif re.search(
|
||||
r'server common name ".+" does not match host name ".+"', error_message
|
||||
):
|
||||
logging.error(
|
||||
"Hostname verification failed when establishing SSL/TLS Handshake with host: %s",
|
||||
secret_dict["host"],
|
||||
)
|
||||
elif re.search(r'no pg_hba.conf entry for host ".+", SSL off', error_message):
|
||||
logging.error(
|
||||
"Unable to establish SSL/TLS handshake, SSL/TLS is enforced on the host: %s",
|
||||
secret_dict["host"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_secret_dict(service_client, arn, stage, token=None):
|
||||
"""Gets the secret dictionary corresponding for the secret arn, stage, and token
|
||||
|
||||
This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
arn (string): The secret ARN or other identifier
|
||||
|
||||
token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired
|
||||
|
||||
stage (string): The stage identifying the secret version
|
||||
|
||||
Returns:
|
||||
SecretDictionary: Secret dictionary
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
|
||||
|
||||
ValueError: If the secret is not valid JSON
|
||||
|
||||
"""
|
||||
required_fields = ["host", "username", "password"]
|
||||
|
||||
# Only do VersionId validation against the stage if a token is passed in
|
||||
if token:
|
||||
secret = service_client.get_secret_value(
|
||||
SecretId=arn, VersionId=token, VersionStage=stage
|
||||
)
|
||||
else:
|
||||
secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage)
|
||||
plaintext = secret["SecretString"]
|
||||
secret_dict = json.loads(plaintext)
|
||||
|
||||
# Run validations against the secret
|
||||
supported_engines = ["postgres", "aurora-postgresql"]
|
||||
if "engine" not in secret_dict or secret_dict["engine"] not in supported_engines:
|
||||
raise KeyError(
|
||||
"Database engine must be set to 'postgres' in order to use this rotation lambda"
|
||||
)
|
||||
for field in required_fields:
|
||||
if field not in secret_dict:
|
||||
raise KeyError("%s key is missing from secret JSON" % field)
|
||||
|
||||
# Parse and return the secret JSON string
|
||||
return secret_dict
|
||||
|
||||
|
||||
def get_environment_bool(variable_name, default_value):
|
||||
"""Loads the environment variable and converts it to the boolean.
|
||||
|
||||
Args:
|
||||
variable_name (string): Name of environment variable
|
||||
|
||||
default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist.
|
||||
|
||||
Returns:
|
||||
bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes'
|
||||
"""
|
||||
variable = os.environ.get(variable_name, str(default_value))
|
||||
return variable.lower() in ["true", "1", "y", "yes"]
|
||||
|
||||
|
||||
def get_random_password(service_client):
|
||||
"""Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment
|
||||
variables. When environment variable is missing sensible defaults are chosen.
|
||||
|
||||
Supported environment variables:
|
||||
- EXCLUDE_CHARACTERS
|
||||
- PASSWORD_LENGTH
|
||||
- EXCLUDE_NUMBERS
|
||||
- EXCLUDE_PUNCTUATION
|
||||
- EXCLUDE_UPPERCASE
|
||||
- EXCLUDE_LOWERCASE
|
||||
- REQUIRE_EACH_INCLUDED_TYPE
|
||||
|
||||
Args:
|
||||
service_client (client): The secrets manager service client
|
||||
|
||||
Returns:
|
||||
string: The randomly generated password.
|
||||
"""
|
||||
passwd = service_client.get_random_password(
|
||||
ExcludeCharacters=os.environ.get("EXCLUDE_CHARACTERS", ":/@\"'\\"),
|
||||
PasswordLength=int(os.environ.get("PASSWORD_LENGTH", 32)),
|
||||
ExcludeNumbers=get_environment_bool("EXCLUDE_NUMBERS", False),
|
||||
ExcludePunctuation=get_environment_bool("EXCLUDE_PUNCTUATION", True),
|
||||
ExcludeUppercase=get_environment_bool("EXCLUDE_UPPERCASE", False),
|
||||
ExcludeLowercase=get_environment_bool("EXCLUDE_LOWERCASE", False),
|
||||
RequireEachIncludedType=get_environment_bool(
|
||||
"REQUIRE_EACH_INCLUDED_TYPE", True
|
||||
),
|
||||
)
|
||||
return passwd["RandomPassword"]
|
||||
173
infra/terraform/db_users/locals.tf
Normal file
173
infra/terraform/db_users/locals.tf
Normal file
@@ -0,0 +1,173 @@
|
||||
locals {
|
||||
env_roles = {
|
||||
staging = { dev_users = "ro", ops_users = "rw", sa_rw_users = "rw", sa_ro_users = "ro", admin_users = "admin" }
|
||||
production = { dev_users = "ro", ops_users = "ro", sa_rw_users = "rw", sa_ro_users = "ro", admin_users = "admin" }
|
||||
}
|
||||
|
||||
# List of application user identities
|
||||
app_users = {
|
||||
dev_users = [
|
||||
"harsh",
|
||||
]
|
||||
ops_users = [
|
||||
"piotr",
|
||||
]
|
||||
admin_users = [
|
||||
"johannes",
|
||||
"matti",
|
||||
]
|
||||
sa_rw_users = [
|
||||
"formbricks-app",
|
||||
]
|
||||
}
|
||||
|
||||
# Flatten users across all teams, creating a map of username => role
|
||||
db_users = merge([
|
||||
for team, users in local.app_users : {
|
||||
for user in users : user => {
|
||||
role = local.env_roles[var.env_name][team]
|
||||
}
|
||||
}
|
||||
]...)
|
||||
|
||||
# FIXME: this shouldn't be hardcoded here
|
||||
rds_database_name = "formbricks-cloud"
|
||||
|
||||
role_prefix = replace(local.rds_database_name, "-", "_")
|
||||
|
||||
# Map of username => role
|
||||
sql_users_map = merge([
|
||||
for team, users in local.app_users : {
|
||||
for user in users : user => {
|
||||
role = "${local.role_prefix}_user_${local.env_roles[var.env_name][team]}"
|
||||
}
|
||||
}
|
||||
]...)
|
||||
|
||||
# SQL to create read-only role
|
||||
sql_create_read_only_role = {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
BEGIN
|
||||
-- Create the read-only role if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${local.role_prefix}_user_ro') THEN
|
||||
CREATE ROLE ${local.role_prefix}_user_ro;
|
||||
END IF;
|
||||
|
||||
-- Loop through all schemas in the database, excluding system schemas
|
||||
FOR schema_name IN
|
||||
SELECT schemata.schema_name
|
||||
FROM information_schema.schemata AS schemata
|
||||
WHERE schemata.catalog_name = '${local.rds_database_name}'
|
||||
AND schemata.schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- Grant USAGE on the schema
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA %I TO ${local.role_prefix}_user_ro;', schema_name);
|
||||
|
||||
-- Grant SELECT on all tables in the schema
|
||||
EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_ro;', schema_name);
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
|
||||
# SQL to create read-write role
|
||||
sql_create_read_write_role = {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
BEGIN
|
||||
-- Create the read-write role if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${local.role_prefix}_user_rw') THEN
|
||||
CREATE ROLE ${local.role_prefix}_user_rw;
|
||||
END IF;
|
||||
|
||||
-- Loop through all schemas in the database, excluding system schemas
|
||||
FOR schema_name IN
|
||||
SELECT schemata.schema_name
|
||||
FROM information_schema.schemata AS schemata
|
||||
WHERE schemata.catalog_name = '${local.rds_database_name}'
|
||||
AND schemata.schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- Grant USAGE and CREATE on the schema
|
||||
EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO ${local.role_prefix}_user_rw;', schema_name);
|
||||
|
||||
-- Grant CRUD permissions on all existing tables
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_rw;', schema_name);
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
|
||||
# SQL to create admin role
|
||||
sql_create_admin_role = {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
BEGIN
|
||||
-- Create the admin role if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${local.role_prefix}_user_admin') THEN
|
||||
CREATE ROLE ${local.role_prefix}_user_admin;
|
||||
END IF;
|
||||
|
||||
-- Loop through all schemas in the database, excluding system schemas
|
||||
FOR schema_name IN
|
||||
SELECT schemata.schema_name
|
||||
FROM information_schema.schemata AS schemata
|
||||
WHERE schemata.catalog_name = '${local.rds_database_name}'
|
||||
AND schemata.schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- Grant USAGE and CREATE on the schema (allowing schema usage and object creation)
|
||||
EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
|
||||
-- Grant INSERT, UPDATE, DELETE on existing tables in the schema
|
||||
EXECUTE format('GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
|
||||
-- Grant full privileges on schema (implicitly includes ability to alter the schema)
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
|
||||
-- Grant the ability to drop tables (delete tables) by owning the tables
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I TO ${local.role_prefix}_user_admin;', schema_name);
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate SQL statements to create users and set passwords
|
||||
sql_create_user = {
|
||||
for user, user_info in local.sql_users_map : user => {
|
||||
sql = <<EOF
|
||||
DO
|
||||
\$\$
|
||||
BEGIN
|
||||
-- Create user if it does not exist
|
||||
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${user}') THEN
|
||||
EXECUTE format('CREATE USER %I WITH PASSWORD %L;', '${user}', '${random_password.db_user_secrets[user].result}');
|
||||
ELSE
|
||||
-- Update password if the user already exists
|
||||
EXECUTE format('ALTER USER %I WITH PASSWORD %L;', '${user}', '${random_password.db_user_secrets[user].result}');
|
||||
END IF;
|
||||
|
||||
-- Ensure role exists
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user_info.role}') THEN
|
||||
RAISE EXCEPTION 'Role ${user_info.role} does not exist';
|
||||
END IF;
|
||||
|
||||
-- Assign role to the user
|
||||
EXECUTE format('GRANT %I TO %I;', '${user_info.role}', '${user}');
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
12
infra/terraform/db_users/provider.tf
Normal file
12
infra/terraform/db_users/provider.tf
Normal file
@@ -0,0 +1,12 @@
|
||||
provider "aws" {
|
||||
region = "eu-central-1"
|
||||
}
|
||||
|
||||
terraform {
|
||||
backend "s3" {
|
||||
bucket = "715841356175-terraform"
|
||||
key = "formbricks/db_users/terraform.tfstate"
|
||||
region = "eu-central-1"
|
||||
dynamodb_table = "terraform-lock"
|
||||
}
|
||||
}
|
||||
77
infra/terraform/db_users/roles.tf
Normal file
77
infra/terraform/db_users/roles.tf
Normal file
@@ -0,0 +1,77 @@
|
||||
module "create_postgres_user_read_only_role" {
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUserRoles"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_read_only_role.sql)
|
||||
]
|
||||
}
|
||||
|
||||
module "create_postgres_user_read_write_role" {
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUserRoles"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_read_write_role.sql)
|
||||
]
|
||||
|
||||
depends_on = [
|
||||
module.create_postgres_user_read_only_role
|
||||
]
|
||||
}
|
||||
|
||||
module "create_postgres_user_admin_role" {
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUserRoles"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_admin_role.sql)
|
||||
]
|
||||
|
||||
depends_on = [
|
||||
module.create_postgres_user_read_write_role
|
||||
]
|
||||
}
|
||||
|
||||
# Create a SQL users
|
||||
module "create_postgres_user" {
|
||||
for_each = {
|
||||
for user, user_info in local.sql_users_map :
|
||||
user => user_info
|
||||
if var.env_name != "localstack"
|
||||
}
|
||||
|
||||
source = "digitickets/cli/aws"
|
||||
version = "7.0.0"
|
||||
|
||||
role_session_name = "CreatePostgresUser"
|
||||
aws_cli_commands = [
|
||||
"rds-data", "execute-statement",
|
||||
format("--resource-arn=%s", data.terraform_remote_state.main.outputs.rds["stage"].cluster_arn),
|
||||
format("--secret-arn=%s", data.aws_secretsmanager_secret.rds_credentials.arn),
|
||||
format("--region=%s", data.aws_region.selected.name),
|
||||
format("--database=%s", local.rds_database_name),
|
||||
format("--sql=\"%s\"", local.sql_create_user[each.key].sql)
|
||||
]
|
||||
}
|
||||
63
infra/terraform/db_users/secrets.tf
Normal file
63
infra/terraform/db_users/secrets.tf
Normal file
@@ -0,0 +1,63 @@
|
||||
resource "random_password" "db_user_secrets" {
|
||||
for_each = local.db_users
|
||||
length = 32
|
||||
numeric = true
|
||||
upper = true
|
||||
special = false
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_user_secrets" {
|
||||
for_each = local.db_users
|
||||
name = "rds-db-credentials/${data.terraform_remote_state.main.outputs.rds["stage"].cluster_resource_id}/${each.key}"
|
||||
description = "RDS database ${data.terraform_remote_state.main.outputs.rds["stage"].cluster_id} credentials for ${each.key}"
|
||||
kms_key_id = data.aws_kms_key.secretsmanager.id
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "db_user_secrets" {
|
||||
for_each = aws_secretsmanager_secret.db_user_secrets
|
||||
secret_id = each.value.id
|
||||
secret_string = jsonencode({
|
||||
engine = "postgres"
|
||||
host = data.terraform_remote_state.main.outputs.rds["stage"].cluster_endpoint
|
||||
username = each.key
|
||||
password = random_password.db_user_secrets[each.key].result
|
||||
dbname = local.rds_database_name
|
||||
port = data.terraform_remote_state.main.outputs.rds["stage"].cluster_port
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_policy" "db_user_secrets" {
|
||||
for_each = aws_secretsmanager_secret.db_user_secrets
|
||||
secret_arn = each.value.arn
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17",
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Deny",
|
||||
Principal = "*",
|
||||
Action = ["secretsmanager:GetSecretValue"],
|
||||
Resource = each.value.arn,
|
||||
Condition = {
|
||||
StringNotLike = {
|
||||
"aws:userId" = flatten(concat([
|
||||
"*:${each.key}@formbricks.com", "*:piotr@formbricks.com"
|
||||
]))
|
||||
|
||||
},
|
||||
ArnNotEquals = {
|
||||
"aws:PrincipalArn" = module.lambda_rotate_db_secret.lambda_function_arn
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_rotation" "db_user_secrets" {
|
||||
for_each = aws_secretsmanager_secret.db_user_secrets
|
||||
secret_id = each.value.id
|
||||
rotation_lambda_arn = module.lambda_rotate_db_secret.lambda_function_arn
|
||||
rotation_rules {
|
||||
automatically_after_days = 1
|
||||
}
|
||||
}
|
||||
6
infra/terraform/db_users/variables.tf
Normal file
6
infra/terraform/db_users/variables.tf
Normal file
@@ -0,0 +1,6 @@
|
||||
#
|
||||
variable "env_name" {
|
||||
description = "env_name"
|
||||
type = string
|
||||
default = "staging"
|
||||
}
|
||||
10
infra/terraform/db_users/versions.tf
Normal file
10
infra/terraform/db_users/versions.tf
Normal file
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.46"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
infra/terraform/locals.tf
Normal file
34
infra/terraform/locals.tf
Normal file
@@ -0,0 +1,34 @@
|
||||
locals {
|
||||
project = "formbricks"
|
||||
environment = "prod"
|
||||
name = "${local.project}-${local.environment}"
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
azs = slice(data.aws_availability_zones.available.names, 0, 3)
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = local.environment
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = local.name
|
||||
}
|
||||
tags_map = {
|
||||
prod = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-prod"
|
||||
}
|
||||
stage = {
|
||||
Project = local.project
|
||||
Environment = "stage"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
domain = "k8s.formbricks.com"
|
||||
karpetner_helm_version = "1.3.1"
|
||||
karpenter_namespace = "karpenter"
|
||||
}
|
||||
@@ -1,38 +1,3 @@
|
||||
locals {
|
||||
project = "formbricks"
|
||||
environment = "prod"
|
||||
name = "${local.project}-${local.environment}"
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
azs = slice(data.aws_availability_zones.available.names, 0, 3)
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = local.environment
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = local.name
|
||||
}
|
||||
tags_map = {
|
||||
prod = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-prod"
|
||||
}
|
||||
stage = {
|
||||
Project = local.project
|
||||
Environment = "stage"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
domain = "k8s.formbricks.com"
|
||||
karpetner_helm_version = "1.3.1"
|
||||
karpenter_namespace = "karpenter"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route53 Hosted Zone
|
||||
################################################################################
|
||||
@@ -131,7 +96,7 @@ module "ebs_csi_driver_irsa" {
|
||||
|
||||
module "eks" {
|
||||
source = "terraform-aws-modules/eks/aws"
|
||||
version = "20.33.1"
|
||||
version = "20.37.2"
|
||||
|
||||
cluster_name = "${local.name}-eks"
|
||||
cluster_version = "1.32"
|
||||
@@ -149,14 +114,14 @@ module "eks" {
|
||||
most_recent = true
|
||||
}
|
||||
aws-ebs-csi-driver = {
|
||||
most_recent = true
|
||||
addon_version = "v1.46.0-eksbuild.1"
|
||||
service_account_role_arn = module.ebs_csi_driver_irsa.iam_role_arn
|
||||
}
|
||||
kube-proxy = {
|
||||
most_recent = true
|
||||
}
|
||||
vpc-cni = {
|
||||
most_recent = true
|
||||
addon_version = "v1.20.0-eksbuild.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,125 +243,125 @@ output "karpenter_node_role" {
|
||||
|
||||
|
||||
|
||||
resource "helm_release" "karpenter_crds" {
|
||||
name = "karpenter-crds"
|
||||
repository = "oci://public.ecr.aws/karpenter"
|
||||
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
chart = "karpenter-crd"
|
||||
version = "1.3.1"
|
||||
namespace = local.karpenter_namespace
|
||||
values = [
|
||||
<<-EOT
|
||||
webhook:
|
||||
enabled: true
|
||||
serviceNamespace: ${local.karpenter_namespace}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
# resource "helm_release" "karpenter_crds" {
|
||||
# name = "karpenter-crds"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter-crd"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# webhook:
|
||||
# enabled: true
|
||||
# serviceNamespace: ${local.karpenter_namespace}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
|
||||
resource "helm_release" "karpenter" {
|
||||
name = "karpenter"
|
||||
repository = "oci://public.ecr.aws/karpenter"
|
||||
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
chart = "karpenter"
|
||||
version = "1.3.1"
|
||||
namespace = local.karpenter_namespace
|
||||
skip_crds = true
|
||||
# resource "helm_release" "karpenter" {
|
||||
# name = "karpenter"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# skip_crds = true
|
||||
#
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# nodeSelector:
|
||||
# karpenter.sh/controller: 'true'
|
||||
# dnsPolicy: Default
|
||||
# settings:
|
||||
# clusterName: ${module.eks.cluster_name}
|
||||
# clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
# interruptionQueue: ${module.karpenter.queue_name}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# resource "kubernetes_manifest" "ec2_node_class" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.k8s.aws/v1"
|
||||
# kind = "EC2NodeClass"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# amiSelectorTerms = [
|
||||
# {
|
||||
# alias = "bottlerocket@latest"
|
||||
# }
|
||||
# ]
|
||||
# role = module.karpenter.node_iam_role_name
|
||||
# subnetSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# securityGroupSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
values = [
|
||||
<<-EOT
|
||||
nodeSelector:
|
||||
karpenter.sh/controller: 'true'
|
||||
dnsPolicy: Default
|
||||
settings:
|
||||
clusterName: ${module.eks.cluster_name}
|
||||
clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
interruptionQueue: ${module.karpenter.queue_name}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "ec2_node_class" {
|
||||
manifest = {
|
||||
apiVersion = "karpenter.k8s.aws/v1"
|
||||
kind = "EC2NodeClass"
|
||||
metadata = {
|
||||
name = "default"
|
||||
}
|
||||
spec = {
|
||||
amiSelectorTerms = [
|
||||
{
|
||||
alias = "bottlerocket@latest"
|
||||
}
|
||||
]
|
||||
role = module.karpenter.node_iam_role_name
|
||||
subnetSelectorTerms = [
|
||||
{
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
]
|
||||
securityGroupSelectorTerms = [
|
||||
{
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
]
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "node_pool" {
|
||||
manifest = {
|
||||
apiVersion = "karpenter.sh/v1"
|
||||
kind = "NodePool"
|
||||
metadata = {
|
||||
name = "default"
|
||||
}
|
||||
spec = {
|
||||
template = {
|
||||
spec = {
|
||||
nodeClassRef = {
|
||||
group = "karpenter.k8s.aws"
|
||||
kind = "EC2NodeClass"
|
||||
name = "default"
|
||||
}
|
||||
requirements = [
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-family"
|
||||
operator = "In"
|
||||
values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-cpu"
|
||||
operator = "In"
|
||||
values = ["2", "4", "8"]
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
operator = "In"
|
||||
values = ["nitro"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
limits = {
|
||||
cpu = 1000
|
||||
}
|
||||
disruption = {
|
||||
consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
consolidateAfter = "30s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# resource "kubernetes_manifest" "node_pool" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.sh/v1"
|
||||
# kind = "NodePool"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# template = {
|
||||
# spec = {
|
||||
# nodeClassRef = {
|
||||
# group = "karpenter.k8s.aws"
|
||||
# kind = "EC2NodeClass"
|
||||
# name = "default"
|
||||
# }
|
||||
# requirements = [
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-family"
|
||||
# operator = "In"
|
||||
# values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-cpu"
|
||||
# operator = "In"
|
||||
# values = ["2", "4", "8"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
# operator = "In"
|
||||
# values = ["nitro"]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
# limits = {
|
||||
# cpu = 1000
|
||||
# }
|
||||
# disruption = {
|
||||
# consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
# consolidateAfter = "30s"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
module "eks_blueprints_addons" {
|
||||
source = "aws-ia/eks-blueprints-addons/aws"
|
||||
|
||||
10
infra/terraform/outputs.tf
Normal file
10
infra/terraform/outputs.tf
Normal file
@@ -0,0 +1,10 @@
|
||||
output "rds" {
|
||||
description = "RDS created for cluster"
|
||||
value = module.rds-aurora
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "rds_secret_staging_arn" {
|
||||
description = "RDS secret created for cluster"
|
||||
value = aws_secretsmanager_secret.rds_credentials["stage"].arn
|
||||
}
|
||||
@@ -75,5 +75,4 @@ module "rds-aurora" {
|
||||
}
|
||||
|
||||
tags = local.tags_map[each.key]
|
||||
|
||||
}
|
||||
|
||||
@@ -22,3 +22,23 @@ resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "rds_credentials" {
|
||||
for_each = local.envs
|
||||
name = "${each.key}/formbricks/terraform/rds/credentials"
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "rds_credentials" {
|
||||
for_each = local.envs
|
||||
secret_id = aws_secretsmanager_secret.rds_credentials[each.key].id
|
||||
secret_string = <<EOF
|
||||
{
|
||||
"username": "${module.rds-aurora[each.key].cluster_master_username}",
|
||||
"password": "${random_password.postgres[each.key].result}",
|
||||
"engine": data.aws_rds_engine_version.postgresql.engine,
|
||||
"host": "${module.rds-aurora[each.key].cluster_endpoint}",
|
||||
"port": ${module.rds-aurora[each.key].cluster_port},
|
||||
"dbClusterIdentifier": "${module.rds-aurora[each.key].cluster_id}"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ExpandIcon } from "@/components/icons/expand-icon";
|
||||
import { ImageDownIcon } from "@/components/icons/image-down-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
|
||||
import { useState } from "preact/hooks";
|
||||
@@ -72,23 +74,9 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
href={imgUrl ? imgUrl : convertToEmbedUrl(videoUrl ?? "")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={"Open in new tab"}
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
{imgUrl ? <ImageDownIcon size={20} /> : <ExpandIcon size={20} />}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Survey({
|
||||
<LanguageSwitch
|
||||
surveyLanguages={localSurvey.languages}
|
||||
setSelectedLanguageCode={setselectedLanguage}
|
||||
hoverColor={styling.inputColor?.light ?? "#f8fafc"}
|
||||
hoverColor={styling.inputColor?.light ?? "#000000"}
|
||||
borderRadius={styling.roundness ?? 8}
|
||||
/>
|
||||
)}
|
||||
@@ -776,7 +776,7 @@ export function Survey({
|
||||
{isCloseButtonVisible && (
|
||||
<SurveyCloseButton
|
||||
onClose={onClose}
|
||||
hoverColor={styling.inputColor?.light ?? "#f8fafc"}
|
||||
hoverColor={styling.inputColor?.light ?? "#000000"}
|
||||
borderRadius={styling.roundness ?? 8}
|
||||
/>
|
||||
)}
|
||||
|
||||
27
packages/surveys/src/components/icons/expand-icon.test.tsx
Normal file
27
packages/surveys/src/components/icons/expand-icon.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/preact";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ExpandIcon } from "./expand-icon";
|
||||
|
||||
describe("ExpandIcon", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders SVG with correct attributes", () => {
|
||||
const { container } = render(<ExpandIcon />);
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
|
||||
expect(svg).toHaveAttribute("fill", "none");
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("applies additional className", () => {
|
||||
const { container } = render(<ExpandIcon className="custom-class" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
28
packages/surveys/src/components/icons/expand-icon.tsx
Normal file
28
packages/surveys/src/components/icons/expand-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExpandIconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ExpandIcon = ({ className = "", size = 24 }: ExpandIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={cn("lucide lucide-expand", className)}>
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/preact";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ImageDownIcon } from "./image-down-icon";
|
||||
|
||||
describe("ImageDownIcon", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders SVG with correct attributes", () => {
|
||||
const { container } = render(<ImageDownIcon />);
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
|
||||
expect(svg).toHaveAttribute("fill", "none");
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("applies additional className", () => {
|
||||
const { container } = render(<ImageDownIcon className="custom-class" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
28
packages/surveys/src/components/icons/image-down-icon.tsx
Normal file
28
packages/surveys/src/components/icons/image-down-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageDownIconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ImageDownIcon = ({ className = "", size = 24 }: ImageDownIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={cn("lucide lucide-image-down-icon lucide-image-down", className)}>
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
|
||||
<path d="m14 19 3 3v-5.5" />
|
||||
<path d="m17 22 3-3" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
import { QuestionMedia } from "@/components/general/question-media";
|
||||
import { Subheader } from "@/components/general/subheader";
|
||||
import { ImageDownIcon } from "@/components/icons/image-down-icon";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getOriginalFileNameFromUrl } from "@/lib/storage";
|
||||
@@ -199,23 +200,7 @@ export function PictureSelectionQuestion({
|
||||
}}
|
||||
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
|
||||
<span className="fb-sr-only">Open in new tab</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="lucide lucide-image-down-icon lucide-image-down">
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
|
||||
<path d="m14 19 3 3v-5.5" />
|
||||
<path d="m17 22 3-3" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
</svg>
|
||||
<ImageDownIcon />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -31,6 +31,12 @@ export const ZResponseFilterCondition = z.enum([
|
||||
"isEmpty",
|
||||
"isNotEmpty",
|
||||
"isAnyOf",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
"startsWith",
|
||||
"doesNotStartWith",
|
||||
"endsWith",
|
||||
"doesNotEndWith",
|
||||
]);
|
||||
|
||||
export type TResponseDataValue = z.infer<typeof ZResponseDataValue>;
|
||||
@@ -149,6 +155,36 @@ const ZResponseFilterCriteriaIsAnyOf = z.object({
|
||||
value: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaContains = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.contains),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDoesNotContain = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.doesNotContain),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaStartsWith = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.startsWith),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDoesNotStartWith = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.doesNotStartWith),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaEndsWith = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.endsWith),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDoesNotEndWith = z.object({
|
||||
op: z.literal(ZResponseFilterCondition.Values.doesNotEndWith),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaFilledOut = z.object({
|
||||
op: z.literal("filledOut"),
|
||||
});
|
||||
@@ -217,10 +253,16 @@ export const ZResponseFilterCriteria = z.object({
|
||||
|
||||
meta: z
|
||||
.record(
|
||||
z.object({
|
||||
op: z.enum(["equals", "notEquals"]),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
})
|
||||
z.union([
|
||||
ZResponseFilterCriteriaDataEquals,
|
||||
ZResponseFilterCriteriaDataNotEquals,
|
||||
ZResponseFilterCriteriaContains,
|
||||
ZResponseFilterCriteriaDoesNotContain,
|
||||
ZResponseFilterCriteriaStartsWith,
|
||||
ZResponseFilterCriteriaDoesNotStartWith,
|
||||
ZResponseFilterCriteriaEndsWith,
|
||||
ZResponseFilterCriteriaDoesNotEndWith,
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user