Compare commits

..

4 Commits

Author SHA1 Message Date
Matti Nannt
68d0af027a chore: add missing translations 2025-12-08 13:17:12 +01:00
Matti Nannt
eec02496ca chore: Merge branch 'main' of github.com:formbricks/formbricks into JoelESvensson/main 2025-12-08 13:12:11 +01:00
Joel Ekström Svensson
edc58b5cb9 Use proper Swedish abbreviation colon 2025-11-30 19:23:51 +01:00
Joel Ekström Svensson
4b6b171540 Add Swedish sv-SE translation
https://github.com/formbricks/formbricks/pull/6737 was used as a reference to see how a translation is implemented
2025-11-30 19:23:34 +01:00
17 changed files with 58 additions and 575 deletions

View File

@@ -1,9 +1,9 @@
import { IntegrationType } from "@prisma/client";
import { createHash } from "node:crypto";
import { type CacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
import { getInstanceInfo } from "@/lib/instance";
import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -129,12 +129,15 @@ export const sendTelemetryEvents = async () => {
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
*/
const sendTelemetry = async (lastSent: number) => {
// Get the instance info (hashed oldest organization ID and creation date).
// Get the oldest organization to generate a stable, anonymized instance ID.
// Using the oldest org ensures the ID doesn't change over time.
const instanceInfo = await getInstanceInfo();
if (!instanceInfo) return; // No organization exists, nothing to report
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
if (!oldestOrg) return; // No organization exists, nothing to report
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
// Optimize database queries to reduce connection pool usage:
// Instead of 15 parallel queries (which could exhaust the connection pool),
@@ -245,7 +248,7 @@ const sendTelemetry = async (lastSent: number) => {
version: packageJson.version, // Formbricks version for compatibility tracking
},
temporal: {
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
},
};

View File

@@ -1,50 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { createHash } from "node:crypto";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export type TInstanceInfo = {
instanceId: string;
createdAt: Date;
};
/**
* Returns instance info including the anonymized instance ID and creation date.
*
* The instance ID is a SHA-256 hash of the oldest organization's ID, ensuring
* it remains stable over time. Used for telemetry and license checks.
*
* @returns Instance info with hashed ID and creation date, or `null` if no organizations exist
*/
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
try {
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
if (!oldestOrg) return null;
return {
instanceId: createHash("sha256").update(oldestOrg.id).digest("hex"),
createdAt: oldestOrg.createdAt,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
/**
* Convenience function that returns just the instance ID.
*
* @returns Hashed instance ID, or `null` if no organizations exist
*/
export const getInstanceId = async (): Promise<string | null> => {
const info = await getInstanceInfo();
return info?.instanceId ?? null;
};

View File

@@ -1,87 +0,0 @@
"use server";
import { getServerSession } from "next-auth";
import { getToken } from "next-auth/jwt";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { NEXTAUTH_SECRET } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
/**
* Invalidates the current user's session by deleting it from the database.
* This is called during logout to ensure JWT tokens cannot be reused.
*/
export async function invalidateCurrentSession() {
try {
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const token = await getToken({
req: { headers: { cookie: cookieHeader } } as any,
secret: NEXTAUTH_SECRET,
});
const sessionToken = (token as any)?.sessionToken as string | undefined;
if (sessionToken) {
await prisma.session.deleteMany({ where: { sessionToken } });
logger.info({ sessionToken }, "Invalidated current session by sessionToken");
return;
}
// Fallback: if we can't decode the token, invalidate all sessions for the current user.
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
logger.warn("No active session to invalidate");
return;
}
const result = await prisma.session.deleteMany({ where: { userId: session.user.id } });
logger.info({ userId: session.user.id, sessionsDeleted: result.count }, "Invalidated all user sessions");
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to invalidate current session"
);
// Don't throw - we don't want to block logout if session deletion fails
}
}
/**
* Invalidates all sessions for a given user.
* Useful for "logout from all devices" functionality.
*
* @param userId - The ID of the user whose sessions should be invalidated
* @throws Error if the operation fails
*/
export async function invalidateAllUserSessions(userId: string) {
try {
const result = await prisma.session.deleteMany({
where: { userId },
});
logger.info(
{
userId,
sessionsDeleted: result.count,
},
"Invalidated all user sessions"
);
return result.count;
} catch (error) {
logger.error(
{
userId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to invalidate user sessions"
);
throw error;
}
}

View File

@@ -1,7 +1,6 @@
import { signOut } from "next-auth/react";
import { logger } from "@formbricks/logger";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { invalidateCurrentSession } from "@/modules/auth/actions/invalidate-sessions";
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
interface UseSignOutOptions {
@@ -45,19 +44,11 @@ export const useSignOut = (sessionUser?: SessionUser | null) => {
}
}
// Invalidate session in database before clearing JWT
try {
await invalidateCurrentSession();
} catch (error) {
// Don't block signOut if session invalidation fails
logger.error("Failed to invalidate session:", error);
}
if (options?.clearEnvironmentId) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
}
// Call NextAuth signOut (clears JWT cookie)
// Call NextAuth signOut
return await signOut({
redirect: options?.redirect,
callbackUrl: options?.callbackUrl,

View File

@@ -1,9 +1,6 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { Account, NextAuthOptions } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import crypto from "node:crypto";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TUser } from "@formbricks/types/user";
@@ -16,7 +13,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -34,8 +31,6 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
// Note: Adapter is only used for OAuth providers, not for CredentialsProvider
adapter: PrismaAdapter(prisma) as Adapter,
providers: [
CredentialsProvider({
id: "credentials",
@@ -315,120 +310,30 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
// Use JWT strategy for CredentialsProvider compatibility
// Database sessions via adapter work for OAuth providers only
strategy: "jwt",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async jwt({ token, user }) {
// IMPORTANT:
// This code runs inside `/api/auth/[...nextauth]/route.ts` which wraps and rethrows
// callback errors. So we must NEVER throw here; instead, return an empty token to
// force `getServerSession()` to return null (unauthenticated).
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
// On sign in (when user object is available), create a server-side session record
// and bind it to the JWT as `sessionToken`. This enables revocation on logout.
if (user) {
token.email = user.email;
token.profile = { id: user.id };
if (!existingUser) {
return token;
}
if (user && !token.sessionToken) {
try {
const sessionToken = crypto.randomUUID();
const expires = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await prisma.session.create({
data: {
sessionToken,
userId: user.id,
expires,
},
});
token.sessionToken = sessionToken;
} catch (err) {
logger.error({ err }, "Failed to create server-side session record");
return {};
}
}
// Validate that the server-side session record still exists (revocation).
if (token.sessionToken) {
try {
const session = await prisma.session.findUnique({
where: { sessionToken: token.sessionToken as string },
select: { expires: true },
});
if (!session || session.expires < new Date()) {
return {};
}
} catch (err) {
logger.error({ err }, "Failed to validate server-side session record");
return {};
}
}
// Attach latest user state (e.g., isActive) to token.
const userId = (token.profile as { id?: string } | undefined)?.id;
if (!userId) return {};
// Backfill sessionToken for existing JWTs (e.g. after deploying this change),
// so that logout revocation works without requiring an explicit re-login.
if (!token.sessionToken) {
try {
const sessionToken = crypto.randomUUID();
const expires = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await prisma.session.create({
data: {
sessionToken,
userId,
expires,
},
});
token.sessionToken = sessionToken;
} catch (err) {
logger.error({ err }, "Failed to backfill server-side session record");
return {};
}
}
try {
const existingUser = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!existingUser) return {};
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
} catch (err) {
logger.error({ err }, "Failed to load user for session token");
return {};
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// If token was invalidated (empty token), treat as unauthenticated.
const profile = token.profile as { id?: string } | undefined;
if (!profile?.id) {
// Make downstream checks like `if (!session?.user)` work reliably.
// (Default NextAuth session type allows `user` to be undefined.)
session.user = undefined;
return session;
}
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
const sessionUser = session.user ?? ({} as NonNullable<typeof session.user>);
sessionUser.id = profile.id;
(sessionUser as { id: string; isActive: boolean }).isActive = token.isActive !== false;
session.user = sessionUser;
return session;
},
async signIn({ user, account }: { user: TUser; account: Account }) {

View File

@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
import { prisma } from "@formbricks/database";
import { getInstanceId, getInstanceInfo } from "@/lib/instance";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
@@ -56,7 +55,6 @@ vi.mock("@formbricks/database", () => ({
},
organization: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}));
@@ -72,11 +70,6 @@ vi.mock("@formbricks/logger", () => ({
logger: mockLogger,
}));
vi.mock("@/lib/instance", () => ({
getInstanceId: vi.fn(),
getInstanceInfo: vi.fn(),
}));
// Mock constants as they are used in the original license.ts indirectly
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -109,15 +102,6 @@ describe("License Core Logic", () => {
mockCache.withCache.mockImplementation(async (fn) => await fn());
vi.mocked(prisma.response.count).mockResolvedValue(100);
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "test-org-id",
createdAt: new Date("2024-01-01"),
} as any);
vi.mocked(getInstanceId).mockResolvedValue("test-hashed-instance-id");
vi.mocked(getInstanceInfo).mockResolvedValue({
instanceId: "test-hashed-instance-id",
createdAt: new Date("2024-01-01"),
});
vi.clearAllMocks();
// Mock window to be undefined for server-side tests
vi.stubGlobal("window", undefined);

View File

@@ -9,7 +9,6 @@ import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceId } from "@/lib/instance";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
@@ -261,20 +260,14 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const [instanceId, responseCount] = await Promise.all([
getInstanceId(),
prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
}),
]);
// No organization exists, cannot perform license check
if (!instanceId) return null;
},
});
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
@@ -286,7 +279,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
instanceId,
}),
headers: { "Content-Type": "application/json" },
method: "POST",

View File

@@ -19,7 +19,6 @@
"i18n:generate": "npx lingo.dev@latest i18n"
},
"dependencies": {
"@auth/prisma-adapter": "2.11.1",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",

View File

@@ -1,10 +0,0 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user?: {
id: string;
isActive: boolean;
} & DefaultSession["user"];
}
}

View File

@@ -17,36 +17,36 @@ Integrate the **Formbricks App Survey SDK** into your app using multiple options
<CardGroup cols={2}>
<Card title="HTML" icon="html5" color="orange" href="#html">
All you need to do is add three lines of code to your HTML script, and that's it!
[All you need to do is add three lines of code to your HTML script, and that's it!](https://formbricks.com/docs/app-surveys/framework-guides#html)
</Card>
<Card title="React.js" icon="react" color="lightblue" href="#react-js">
Load our JavaScript library with your environment ID, and you're ready to
go!
[Load our JavaScript library with your environment ID, and you're ready to
go!](https://formbricks.com/docs/app-surveys/framework-guides#react-js)
</Card>
<Card title="Next.js" icon="react" href="#next-js">
Natively add us to your Next.js project, with support for both App and Pages project
structure.
[Natively add us to your Next.js project, with support for both App and Pages project
structure.](https://formbricks.com/docs/app-surveys/framework-guides#next-js)
</Card>
<Card title="Vue.js" icon="vuejs" href="#vue-js">
Learn how to use Formbricks' Vue.js SDK to integrate your surveys into Vue.js applications.
Learn how to use Formbricks' React Native SDK to integrate your surveys into React Native applications.
</Card>
<Card title="React Native" icon="react" color="lightblue" href="#react-native">
Easily integrate our SDK with your React Native app for seamless survey
support.
[Easily integrate our SDK with your React Native app for seamless survey
support.](https://formbricks.com/docs/app-surveys/framework-guides#react-native)
</Card>
<Card title="Swift" icon="swift" color="orange" href="#swift">
Use our iOS SDK to quickly integrate surveys into your iOS
applications.
[Use our iOS SDK to quickly integrate surveys into your iOS
applications.](https://formbricks.com/docs/app-surveys/framework-guides#swift)
</Card>
<Card title="Android" icon="android" color="green" href="#android">
Integrate surveys into your Android applications using our native Kotlin
SDK.
[Integrate surveys into your Android applications using our native Kotlin
SDK.](https://formbricks.com/docs/app-surveys/framework-guides#android)
</Card>
</CardGroup>
@@ -345,8 +345,6 @@ Now, visit the [Validate Your Setup](#validate-your-setup) section to verify you
## Swift
<Info>**Minimum iOS Version:** The Formbricks iOS SDK requires **iOS 16.4** or higher.</Info>
Install the Formbricks iOS SDK using the following steps:
**Swift Package Manager**
@@ -365,7 +363,7 @@ Install the Formbricks iOS SDK using the following steps:
1. Add the following to your `Podfile`:
```ruby
platform :ios, '16.4'
platform :ios, '16.6'
use_frameworks! :linkage => :static
target 'YourTargetName' do
@@ -431,10 +429,6 @@ Now, visit the [Validate Your Setup](#validate-your-setup) section to verify you
## Android
<Info>
**Minimum Android Version:** The Formbricks Android SDK requires **Android 10 (API level 29)** or higher.
</Info>
Install the Formbricks Android SDK using the following steps:
### Installation

View File

@@ -84,12 +84,11 @@
},
"overrides": {
"axios": ">=1.12.2",
"node-forge": ">=1.3.2",
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
}
}
}

View File

@@ -1,99 +0,0 @@
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
import { type SurveyRecord } from "./types";
export const removeEmptyImageAndVideoUrlsFromElements: MigrationScript = {
type: "data",
id: "ohw7fb1f64yfh2vax294agp0",
name: "20251208033316_remove_empty_image_and_video_urls_from_elements",
run: async ({ tx }) => {
// Find all surveys with empty imageUrl or videoUrl
const surveysFindQuery = `
SELECT s.id, s.blocks, s."welcomeCard", s.endings
FROM "Survey" AS s
WHERE EXISTS (
SELECT 1
FROM unnest(s.blocks) AS block
CROSS JOIN jsonb_array_elements(block->'elements') AS element
WHERE element->>'imageUrl' = ''
OR element->>'videoUrl' = ''
) OR s."welcomeCard"->>'fileUrl' = ''
OR s."welcomeCard"->>'videoUrl' = ''
OR EXISTS (
SELECT 1
FROM unnest(s.endings) AS ending
WHERE ending->>'imageUrl' = ''
OR ending->>'videoUrl' = ''
)
`;
const surveysWithEmptyUrls: SurveyRecord[] = await tx.$queryRaw`${Prisma.raw(surveysFindQuery)}`;
logger.info(`Found ${surveysWithEmptyUrls.length.toString()} surveys with empty imageUrl or videoUrl`);
// Process in batches to avoid overwhelming the connection pool
const BATCH_SIZE = 1000;
for (let i = 0; i < surveysWithEmptyUrls.length; i += BATCH_SIZE) {
const batch = surveysWithEmptyUrls.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map((survey) => {
// Clean the blocks
const cleanedBlocks = survey.blocks.map((block) => {
const cleanedElements = block.elements.map((element) => {
const cleanedElement = { ...element };
if (cleanedElement.imageUrl === "") {
delete cleanedElement.imageUrl;
}
if (cleanedElement.videoUrl === "") {
delete cleanedElement.videoUrl;
}
return cleanedElement;
});
return { ...block, elements: cleanedElements };
});
const cleanedWelcomeCard = { ...survey.welcomeCard };
if (cleanedWelcomeCard.fileUrl === "") {
delete cleanedWelcomeCard.fileUrl;
}
if (cleanedWelcomeCard.videoUrl === "") {
delete cleanedWelcomeCard.videoUrl;
}
const cleanedEndings = survey.endings.map((ending) => {
const cleanedEnding = { ...ending };
if (cleanedEnding.imageUrl === "") {
delete cleanedEnding.imageUrl;
}
if (cleanedEnding.videoUrl === "") {
delete cleanedEnding.videoUrl;
}
return cleanedEnding;
});
// Convert JSON arrays to PostgreSQL jsonb[] using array_agg + jsonb_array_elements
const blocksJson = JSON.stringify(cleanedBlocks);
const endingsJson = JSON.stringify(cleanedEndings);
const welcomeCardJson = JSON.stringify(cleanedWelcomeCard);
return tx.$executeRaw`
UPDATE "Survey"
SET
blocks = (SELECT array_agg(elem) FROM jsonb_array_elements(${blocksJson}::jsonb) AS elem),
endings = (SELECT array_agg(elem) FROM jsonb_array_elements(${endingsJson}::jsonb) AS elem),
"welcomeCard" = ${welcomeCardJson}::jsonb
WHERE id = ${survey.id}
`;
});
await Promise.all(batchPromises);
logger.info(
`Processed batch ${(Math.floor(i / BATCH_SIZE) + 1).toString()}/${Math.ceil(surveysWithEmptyUrls.length / BATCH_SIZE).toString()}`
);
}
logger.info(`Successfully cleaned ${surveysWithEmptyUrls.length.toString()} surveys`);
},
};

View File

@@ -1,22 +0,0 @@
export interface SurveyElement {
id: string;
imageUrl?: string;
videoUrl?: string;
}
export interface Block {
id: string;
elements: SurveyElement[];
}
export interface SurveyRecord {
id: string;
blocks: Block[];
welcomeCard: {
fileUrl?: string;
videoUrl?: string;
};
endings: {
imageUrl?: string;
videoUrl?: string;
}[];
}

View File

@@ -1,31 +0,0 @@
-- CreateTable
CREATE TABLE "public"."sessions" (
"id" TEXT NOT NULL,
"session_token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."verification_tokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_session_token_key" ON "public"."sessions"("session_token");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "public"."sessions"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "public"."verification_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "public"."verification_tokens"("identifier", "token");
-- AddForeignKey
ALTER TABLE "public"."sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -832,7 +832,6 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
@@ -848,34 +847,6 @@ model User {
@@index([email])
}
/// Represents an active user session for authentication.
/// Used by NextAuth for database session strategy.
///
/// @property sessionToken - Unique token identifying the session
/// @property userId - The user this session belongs to
/// @property expires - When the session expires
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
/// Stores verification tokens for email verification flows.
/// Used by NextAuth for magic link authentication.
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
/// Defines a segment of contacts based on attributes.
/// Used for targeting surveys to specific user groups.
///

View File

@@ -1,7 +1,7 @@
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZColor, ZId, ZPlacement, ZUrl, getZSafeUrl } from "../common";
import { ZColor, ZId, ZPlacement, getZSafeUrl } from "../common";
import { ZContactAttributes } from "../contact-attribute";
import { type TI18nString, ZI18nString } from "../i18n";
import { ZLanguage } from "../project";
@@ -60,16 +60,16 @@ export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({
headline: ZI18nString.optional(),
subheader: ZI18nString.optional(),
buttonLabel: ZI18nString.optional(),
buttonLink: ZUrl.optional(),
imageUrl: ZUrl.optional(),
videoUrl: ZUrl.optional(),
buttonLink: z.string().optional(),
imageUrl: z.string().optional(),
videoUrl: z.string().optional(),
});
export type TSurveyEndScreenCard = z.infer<typeof ZSurveyEndScreenCard>;
export const ZSurveyRedirectUrlCard = ZSurveyEndingBase.extend({
type: z.literal("redirectToUrl"),
url: ZUrl.optional(),
url: z.string().optional(),
label: z.string().optional(),
});
@@ -143,11 +143,11 @@ export const ZSurveyWelcomeCard = z
enabled: z.boolean(),
headline: ZI18nString.optional(),
subheader: ZI18nString.optional(),
fileUrl: ZUrl.optional(),
fileUrl: z.string().optional(),
buttonLabel: ZI18nString.optional(),
timeToFinish: z.boolean().default(true),
showResponseCount: z.boolean().default(false),
videoUrl: ZUrl.optional(),
videoUrl: z.string().optional(),
})
.refine((schema) => !(schema.enabled && !schema.headline), {
message: "Welcome card must have a headline",

64
pnpm-lock.yaml generated
View File

@@ -6,7 +6,6 @@ settings:
overrides:
axios: '>=1.12.2'
node-forge: '>=1.3.2'
tar-fs: 2.1.4
typeorm: '>=0.3.26'
@@ -108,9 +107,6 @@ importers:
apps/web:
dependencies:
'@auth/prisma-adapter':
specifier: 2.11.1
version: 2.11.1(@prisma/client@6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3))(nodemailer@7.0.11)
'@aws-sdk/client-s3':
specifier: 3.879.0
version: 3.879.0
@@ -925,25 +921,6 @@ packages:
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
'@auth/core@0.41.1':
resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^7.0.7
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@auth/prisma-adapter@2.11.1':
resolution: {integrity: sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6'
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -7822,8 +7799,8 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-forge@1.3.3:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
node-forge@1.3.1:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
engines: {node: '>= 6.13.0'}
node-gyp@8.4.1:
@@ -8259,14 +8236,6 @@ packages:
peerDependencies:
preact: '>=10'
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
preact@10.26.6:
resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==}
@@ -10092,25 +10061,6 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
'@auth/core@0.41.1(nodemailer@7.0.11)':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.0.11
oauth4webapi: 3.8.2
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
optionalDependencies:
nodemailer: 7.0.11
'@auth/prisma-adapter@2.11.1(@prisma/client@6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3))(nodemailer@7.0.11)':
dependencies:
'@auth/core': 0.41.1(nodemailer@7.0.11)
'@prisma/client': 6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3)
transitivePeerDependencies:
- '@simplewebauthn/browser'
- '@simplewebauthn/server'
- nodemailer
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -11751,7 +11701,7 @@ snapshots:
mongodb: 6.16.0(@aws-sdk/credential-providers@3.817.0)(socks@2.8.7)
mssql: 11.0.1
mysql2: 3.14.1
node-forge: 1.3.3
node-forge: 1.3.1
openid-client: 6.5.0
pg: 8.16.0
redis: 4.7.0
@@ -18690,7 +18640,7 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-forge@1.3.3: {}
node-forge@1.3.1: {}
node-gyp@8.4.1:
dependencies:
@@ -19153,12 +19103,6 @@ snapshots:
preact: 10.26.6
pretty-format: 3.8.0
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
preact@10.26.6: {}
prebuild-install@7.1.3: