mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Compare commits
4 Commits
fix/logout
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d0af027a | ||
|
|
eec02496ca | ||
|
|
edc58b5cb9 | ||
|
|
4b6b171540 |
@@ -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
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
apps/web/types/next-auth.d.ts
vendored
10
apps/web/types/next-auth.d.ts
vendored
@@ -1,10 +0,0 @@
|
||||
import { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user?: {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}[];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
64
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user