Compare commits

...

10 Commits

Author SHA1 Message Date
Piyush Gupta 41d60c8a02 chore: custom avatar removal (#6408)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-14 10:17:05 +00:00
Anshuman Pandey a6269f0fd3 fix: disables share tabs when single use is active (#6410) 2025-08-14 08:49:15 +00:00
Dhruwang Jariwala 9c0d0a16a7 fix: hover on survey close button (#6405) 2025-08-14 08:11:15 +00:00
Piyush Gupta c6241f7e7f fix: Inconsistent icon - Picture select vs. question header image (#6409) 2025-08-13 13:09:23 +00:00
Piotr Gaczkowski 92f1c2b75a fix: make terraform apply work again (#6403)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-13 12:19:18 +00:00
Dhruwang Jariwala 4d53291c8a fix: checks and rate limiting for email verification survey action (#6406) 2025-08-13 06:42:08 +00:00
Matti Nannt 14b7a69cea fix: permissions in release workflow (#6399) 2025-08-13 08:35:26 +02:00
Piyush Gupta a9015b008d docs: adds identifier note in saml sso docs (#6402) 2025-08-12 11:18:44 +00:00
Dhruwang Jariwala d19d624c0c feat: filters for url in metadata (#6387)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-08-12 09:37:12 +00:00
Matti Nannt 3edaab6c2b fix: release workflow environment is not accessible (#6398) 2025-08-12 10:31:05 +02:00
74 changed files with 1154 additions and 848 deletions
@@ -37,7 +37,7 @@ on:
permissions:
id-token: write
contents: write
contents: read
jobs:
helmfile-deploy:
+12 -5
View File
@@ -7,12 +7,13 @@ on:
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build:
name: Build & release docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
@@ -20,6 +21,9 @@ jobs:
helm-chart-release:
name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
@@ -29,6 +33,9 @@ jobs:
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
@@ -36,7 +43,7 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
@@ -64,4 +71,4 @@ jobs:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }}
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
@@ -45,7 +45,7 @@ afterEach(() => {
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },
@@ -82,7 +82,7 @@ export const LandingSidebar = ({
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<ProfileAvatar userId={user.id} />
<>
<div className="grow overflow-hidden">
<p
@@ -113,7 +113,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -111,7 +111,6 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",
@@ -342,7 +342,7 @@ export const MainNavigation = ({
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && (
<>
<div
@@ -37,7 +37,6 @@ describe("EnvironmentPage", () => {
id: mockUserId,
name: "Test User",
email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -5,8 +5,6 @@ import {
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -15,8 +13,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
TUserPersonalInfoUpdateInput,
@@ -97,58 +93,6 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
@@ -1,104 +0,0 @@
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import * as fileUploadHooks from "@/app/lib/fileUpload";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Session } from "next-auth";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateAvatarAction: vi.fn(),
removeAvatarAction: vi.fn(),
}));
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const mockSession: Session = {
user: { id: "user-id" },
expires: "session-expires-at",
};
const environmentId = "test-env-id";
describe("EditProfileAvatarForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
url: "new-avatar.jpg",
error: undefined,
});
});
test("renders correctly without an existing image", () => {
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
});
test("renders correctly with an existing image", () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
});
test("handles image removal successfully", async () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
});
});
test("shows error if removeAvatarAction fails", async () => {
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"environments.settings.profile.avatar_update_failed"
);
});
});
});
@@ -1,178 +0,0 @@
"use client";
import {
removeAvatarAction,
updateAvatarAction,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
interface EditProfileAvatarFormProps {
session: Session;
environmentId: string;
imageUrl: string | null;
}
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { t } = useTranslate();
const fileSchema =
typeof window !== "undefined"
? z
.instanceof(FileList)
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
.refine((files) => {
const file = files[0];
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
return allowedTypes.includes(file.type);
}, t("environments.settings.profile.invalid_file_type"))
.refine((files) => {
const file = files[0];
const maxSize = 10 * 1024 * 1024;
return file.size <= maxSize;
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
: z.any();
const formSchema = z.object({
file: fileSchema,
});
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
const handleUpload = async (file: File, environmentId: string) => {
setIsLoading(true);
try {
if (imageUrl) {
// If avatar image already exists, then remove it before update action
await removeAvatarAction({ environmentId });
}
const { url, error } = await handleFileUpload(file, environmentId);
if (error) {
toast.error(error);
setIsLoading(false);
return;
}
await updateAvatarAction({ avatarUrl: url });
router.refresh();
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
setIsLoading(false);
}
setIsLoading(false);
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatarAction({ environmentId });
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
} finally {
setIsLoading(false);
form.reset();
}
};
const onSubmit = async (data: FormValues) => {
const file = data.file[0];
if (file) {
await handleUpload(file, environmentId);
}
};
return (
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
<FormField
name="file"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<div className="flex">
<Button
type="button"
size="sm"
className="mr-2"
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
onClick={() => {
inputRef.current?.click();
}}>
{imageUrl
? t("environments.settings.profile.change_image")
: t("environments.settings.profile.upload_image")}
<input
type="file"
id="hiddenFileInput"
ref={(e) => {
field.ref(e);
inputRef.current = e;
}}
className="hidden"
accept="image/jpeg, image/png, image/webp"
onChange={(e) => {
field.onChange(e.target.files);
form.handleSubmit(onSubmit)();
}}
/>
</Button>
{imageUrl && (
<Button
type="button"
className="mr-2"
variant="destructive"
size="sm"
onClick={handleRemove}>
{t("environments.settings.profile.remove_image")}
</Button>
)}
</div>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
</div>
);
};
@@ -49,15 +49,12 @@ describe("Loading", () => {
);
const loadingCards = screen.getAllByTestId("loading-card");
expect(loadingCards).toHaveLength(3);
expect(loadingCards).toHaveLength(2);
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
expect(loadingCards[1]).toHaveTextContent("common.avatar");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
});
});
@@ -19,11 +19,6 @@ const Loading = () => {
{ classes: "h-6 w-64" },
],
},
{
title: t("common.avatar"),
description: t("environments.settings.profile.organization_identification"),
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.settings.profile.delete_account"),
description: t("environments.settings.profile.confirm_delete_account"),
@@ -55,11 +55,6 @@ vi.mock(
vi.mock("./components/DeleteAccount", () => ({
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
}));
vi.mock("./components/EditProfileAvatarForm", () => ({
EditProfileAvatarForm: ({ _, environmentId }) => (
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
),
}));
vi.mock("./components/EditProfileDetailsForm", () => ({
EditProfileDetailsForm: ({ user }) => (
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
@@ -73,7 +68,6 @@ const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
@@ -117,7 +111,6 @@ describe("ProfilePage", () => {
"AccountSettingsNavbar: env-123 profile"
);
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
@@ -12,7 +12,6 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
@@ -50,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard>
<SettingsCard
title={t("common.avatar")}
description={t("environments.settings.profile.organization_identification")}>
{user && (
<EditProfileAvatarForm
session={session}
environmentId={environmentId}
imageUrl={user.imageUrl}
/>
)}
</SettingsCard>
{user.identityProvider === "email" && (
<SettingsCard
title={t("common.security")}
@@ -126,7 +126,6 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {} },
@@ -128,7 +128,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -145,7 +145,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -291,7 +291,6 @@ const mockUser: TUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -250,7 +250,6 @@ const mockUser: TUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -29,7 +29,7 @@ import {
SquareStack,
UserIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, 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,6 +77,7 @@ export const ShareSurveyModal = ({
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(
() => [
{
@@ -111,6 +112,7 @@ export const ShareSurveyModal = ({
isContactsEnabled,
isFormbricksCloud,
},
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.WEBSITE_EMBED,
@@ -121,6 +123,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.EMAIL,
@@ -131,6 +134,7 @@ 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,
@@ -141,6 +145,7 @@ 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,
@@ -151,6 +156,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.DYNAMIC_POPUP,
@@ -177,9 +183,9 @@ export const ShareSurveyModal = ({
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
@@ -188,9 +194,15 @@ export const ShareSurveyModal = ({
]
);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(
survey.type === "link" ? ShareViaType.ANON_LINKS : ShareViaType.APP
);
const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") {
return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
useEffect(() => {
if (open) {
@@ -198,11 +210,19 @@ 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(ShareViaType.ANON_LINKS);
setActiveId(getDefaultActiveId());
}
};
@@ -34,6 +34,7 @@ interface ShareViewProps {
componentProps: any;
title: string;
description?: string;
disabled?: boolean;
}>;
activeId: ShareViaType | ShareSettingsType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
@@ -109,12 +110,13 @@ 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.id === activeId && !tab.disabled
? "bg-slate-100 font-medium text-slate-900"
: "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}>
isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
@@ -136,9 +138,10 @@ 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.id === activeId && !tab.disabled
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
@@ -158,7 +158,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -174,7 +173,6 @@ const mockSession = {
id: mockUserId,
name: mockUser.name,
email: mockUser.email,
image: mockUser.imageUrl,
role: mockUser.role,
plan: "free",
status: "active",
@@ -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)}
+150
View File
@@ -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", () => {
+30 -12
View File
@@ -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 };
}
}
});
}
-4
View File
@@ -118,7 +118,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -161,7 +160,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -250,7 +248,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -339,7 +336,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
+128
View File
@@ -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 datafield 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", () => {
+60 -3
View File
@@ -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) {
@@ -122,7 +122,6 @@ export const mockUser: TUser = {
name: "mock User",
email: "test@unit.com",
emailVerified: currentDate,
imageUrl: "https://www.google.com",
createdAt: currentDate,
updatedAt: currentDate,
twoFactorEnabled: false,
+1 -13
View File
@@ -3,7 +3,7 @@ import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
@@ -20,10 +20,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/fileValidation", () => ({
isValidImageFile: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
deleteOrganization: vi.fn(),
@@ -39,7 +35,6 @@ describe("User Service", () => {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
role: Role.project_manager,
@@ -200,13 +195,6 @@ describe("User Service", () => {
await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError);
});
test("should throw InvalidInputError when invalid image URL is provided", async () => {
const { isValidImageFile } = await import("@/lib/fileValidation");
vi.mocked(isValidImageFile).mockReturnValue(false);
await expect(updateUser("user1", { imageUrl: "invalid-image-url" })).rejects.toThrow(InvalidInputError);
});
});
describe("deleteUser", () => {
+1 -4
View File
@@ -1,5 +1,4 @@
import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { Prisma } from "@prisma/client";
@@ -8,7 +7,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
import { validateInputs } from "../utils/validate";
@@ -17,7 +16,6 @@ const responseSelection = {
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
@@ -79,7 +77,6 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try {
const updatedUser = await prisma.user.update({
@@ -112,7 +112,6 @@ describe("withAuditLogging", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),
@@ -151,7 +150,6 @@ describe("withAuditLogging", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),
@@ -148,7 +148,6 @@ describe("authOptions", () => {
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
};
@@ -161,7 +160,6 @@ describe("authOptions", () => {
id: fakeUser.id,
email: fakeUser.email,
emailVerified: fakeUser.emailVerified,
imageUrl: fakeUser.imageUrl,
});
});
-1
View File
@@ -206,7 +206,6 @@ export const authOptions: NextAuthOptions = {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
imageUrl: user.imageUrl,
};
},
}),
-1
View File
@@ -5,7 +5,6 @@ export const mockUser: TUser = {
name: "mock User",
email: "john.doe@example.com",
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
imageUrl: "https://www.google.com",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
twoFactorEnabled: false,
-5
View File
@@ -1,4 +1,3 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -11,10 +10,6 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
export const updateUser = async (id: string, data: TUserUpdateInput) => {
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
throw new InvalidInputError("Invalid image file");
}
try {
const updatedUser = await prisma.user.update({
where: {
@@ -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
},
};
@@ -94,7 +94,6 @@ const fullUser = {
updatedAt: new Date(),
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
organizationId: "org1",
@@ -28,7 +28,6 @@ describe("ResponseTimeline", () => {
name: "Test User",
createdAt: new Date(),
updatedAt: new Date(),
imageUrl: null,
objective: null,
role: "founder",
email: "test@example.com",
-2
View File
@@ -51,7 +51,6 @@ export const getSSOProviders = () => [
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
};
},
},
@@ -76,7 +75,6 @@ export const getSSOProviders = () => [
id: profile.id,
email: profile.email,
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
image: null,
};
},
options: {
@@ -13,7 +13,6 @@ export const mockUser: TUser = {
unsubscribedOrganizationIds: [],
},
emailVerified: new Date(),
imageUrl: "https://example.com/image.png",
twoFactorEnabled: false,
identityProvider: "google",
locale: "en-US",
@@ -56,7 +56,6 @@ const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -131,7 +131,6 @@ describe("CreateOrganizationPage", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),
@@ -1,9 +1,8 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user";
import { updateUser } from "./user";
@@ -24,7 +23,6 @@ describe("updateUser", () => {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: "https://example.com/image.png",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
@@ -41,7 +39,6 @@ describe("updateUser", () => {
});
test("successfully updates a user", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const updateData = { name: "Updated Name" };
@@ -55,7 +52,6 @@ describe("updateUser", () => {
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
@@ -72,17 +68,7 @@ describe("updateUser", () => {
expect(result).toEqual(mockUser);
});
test("throws InvalidInputError when image file is invalid", async () => {
vi.mocked(isValidImageFile).mockReturnValue(false);
const updateData = { imageUrl: "invalid-image.xyz" };
await expect(updateUser("user-123", updateData)).rejects.toThrow(InvalidInputError);
expect(prisma.user.update).not.toHaveBeenCalled();
});
test("throws ResourceNotFoundError when user does not exist", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
@@ -96,8 +82,6 @@ describe("updateUser", () => {
});
test("re-throws other errors", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
const otherError = new Error("Some other error");
vi.mocked(prisma.user.update).mockRejectedValue(otherError);
@@ -1,14 +1,11 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try {
const updatedUser = await prisma.user.update({
where: {
@@ -20,7 +17,6 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
+10
View File
@@ -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);
@@ -12,13 +12,6 @@ vi.mock("boring-avatars", () => ({
),
}));
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, width, height, className, alt }: any) => (
<img src={src} width={width} height={height} className={className} alt={alt} data-testid="next-image" />
),
}));
describe("Avatar Components", () => {
afterEach(() => {
cleanup();
@@ -44,7 +37,7 @@ describe("Avatar Components", () => {
});
describe("ProfileAvatar", () => {
test("renders Boring Avatar when imageUrl is not provided", () => {
test("renders Boring Avatar", () => {
render(<ProfileAvatar userId="user-123" />);
const avatar = screen.getByTestId("boring-avatar-bauhaus");
@@ -52,32 +45,5 @@ describe("Avatar Components", () => {
expect(avatar).toHaveAttribute("data-size", "40");
expect(avatar).toHaveAttribute("data-name", "user-123");
});
test("renders Boring Avatar when imageUrl is null", () => {
render(<ProfileAvatar userId="user-123" imageUrl={null} />);
const avatar = screen.getByTestId("boring-avatar-bauhaus");
expect(avatar).toBeInTheDocument();
});
test("renders Image component when imageUrl is provided", () => {
render(<ProfileAvatar userId="user-123" imageUrl="https://example.com/avatar.jpg" />);
const image = screen.getByTestId("next-image");
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute("src", "https://example.com/avatar.jpg");
expect(image).toHaveAttribute("width", "40");
expect(image).toHaveAttribute("height", "40");
expect(image).toHaveAttribute("alt", "Avatar placeholder");
expect(image).toHaveClass("h-10", "w-10", "rounded-full", "object-cover");
});
test("renders Image component with different imageUrl", () => {
render(<ProfileAvatar userId="user-123" imageUrl="https://example.com/different-avatar.png" />);
const image = screen.getByTestId("next-image");
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute("src", "https://example.com/different-avatar.png");
});
});
});
@@ -1,5 +1,4 @@
import Avatar from "boring-avatars";
import Image from "next/image";
const colors = ["#00C4B8", "#ccfbf1", "#334155"];
@@ -13,20 +12,8 @@ export const PersonAvatar: React.FC<PersonAvatarProps> = ({ personId }) => {
interface ProfileAvatar {
userId: string;
imageUrl?: string | null;
}
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId, imageUrl }) => {
if (imageUrl) {
return (
<Image
src={imageUrl}
width="40"
height="40"
className="h-10 w-10 rounded-full object-cover"
alt="Avatar placeholder"
/>
);
}
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId }) => {
return <Avatar size={40} name={userId} variant="bauhaus" colors={colors} />;
};
+1 -1
View File
@@ -82,7 +82,7 @@
"@vercel/functions": "2.2.8",
"@vercel/og": "0.8.5",
"bcryptjs": "3.0.2",
"boring-avatars": "1.11.2",
"boring-avatars": "2.0.1",
"cache-manager": "6.4.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -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
View 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
View File
@@ -0,0 +1,3 @@
.terraform/
builds
/.direnv/
+61
View 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
}
+30
View File
@@ -0,0 +1,30 @@
{
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;
};
in
with pkgs;
{
devShells.default = mkShell {
buildInputs = [
awscli
terraform
];
};
}
);
}
+139 -98
View File
@@ -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",
]
}
+119 -119
View File
@@ -131,7 +131,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,7 +149,7 @@ 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 = {
@@ -278,125 +278,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"
@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `imageUrl` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "imageUrl";
-1
View File
@@ -824,7 +824,6 @@ model User {
email String @unique
emailVerified DateTime? @map(name: "email_verified")
imageUrl String?
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
backupCodes String?
-1
View File
@@ -57,7 +57,6 @@ export const ZUser = z.object({
Omit<
User,
| "emailVerified"
| "imageUrl"
| "twoFactorSecret"
| "twoFactorEnabled"
| "backupCodes"
@@ -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 ?? "#000000"}
hoverColor={styling.inputColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
/>
)}
@@ -776,7 +776,7 @@ export function Survey({
{isCloseButtonVisible && (
<SurveyCloseButton
onClose={onClose}
hoverColor={styling.inputColor?.light ?? "#000000"}
hoverColor={styling.inputColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
/>
)}
@@ -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");
});
});
@@ -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");
});
});
@@ -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>
))}
+46 -4
View File
@@ -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(),
});
-2
View File
@@ -48,7 +48,6 @@ export const ZUser = z.object({
name: ZUserName,
email: ZUserEmail,
emailVerified: z.date().nullable(),
imageUrl: z.string().url().nullable(),
twoFactorEnabled: z.boolean(),
identityProvider: ZUserIdentityProvider,
createdAt: z.date(),
@@ -70,7 +69,6 @@ export const ZUserUpdateInput = z.object({
password: ZUserPassword.optional(),
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
imageUrl: z.string().nullish(),
notificationSettings: ZUserNotificationSettings.optional(),
locale: ZUserLocale.optional(),
lastLoginAt: z.date().nullish(),
+11 -5
View File
@@ -292,8 +292,8 @@ importers:
specifier: 3.0.2
version: 3.0.2
boring-avatars:
specifier: 1.11.2
version: 1.11.2
specifier: 2.0.1
version: 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
cache-manager:
specifier: 6.4.3
version: 6.4.3
@@ -5069,8 +5069,11 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
boring-avatars@1.11.2:
resolution: {integrity: sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A==}
boring-avatars@2.0.1:
resolution: {integrity: sha512-TeBnZrp7WxHcQPuLhGQamklgNqaL7eUAUh3E11kFj9rTn0Hari2ZKVTchqNrp62UOHN/XOe5bZGcbzVGwHjHwg==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
bowser@2.11.0:
resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==}
@@ -15116,7 +15119,10 @@ snapshots:
boolbase@1.0.0: {}
boring-avatars@1.11.2: {}
boring-avatars@2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
bowser@2.11.0: {}