mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-16 03:21:30 -05:00
Compare commits
6 Commits
chore/sso_
...
docs/dogfo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0a55951c | ||
|
|
12c6d9895f | ||
|
|
a1a11b2bb8 | ||
|
|
0653c6a59f | ||
|
|
b6d793e109 | ||
|
|
439dd0b44e |
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="" />
|
||||
<div className="flex h-9 animate-pulse gap-2">
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<SkeletonLoader type="summary" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
|
||||
const Loading = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.responses")} />
|
||||
<div className="flex h-9 animate-pulse gap-1.5">
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<SkeletonLoader type="responseTable" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["surveyId", "singleUseId"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["displayId"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -129,6 +129,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
const target = (error.meta?.target as string[]) ?? [];
|
||||
if (target?.includes("singleUseId")) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message, undefined, true);
|
||||
}
|
||||
|
||||
const response = getUnexpectedPublicErrorResponse();
|
||||
reportApiError({
|
||||
request,
|
||||
|
||||
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("should return a conflict response", () => {
|
||||
const message = "Resource already exists";
|
||||
const details = { field: "singleUseId" };
|
||||
const response = responses.conflictResponse(message, details);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "conflict",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined details", () => {
|
||||
const message = "Resource already exists";
|
||||
const response = responses.conflictResponse(message);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "conflict",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should include CORS headers when cors is true", () => {
|
||||
const message = "Resource already exists";
|
||||
const response = responses.conflictResponse(message, undefined, true);
|
||||
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Resource already exists";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.conflictResponse(message, undefined, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooManyRequestsResponse", () => {
|
||||
test("should return a too many requests response", () => {
|
||||
const message = "Rate limit exceeded";
|
||||
|
||||
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
|
||||
| "method_not_allowed"
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "too_many_requests";
|
||||
| "too_many_requests"
|
||||
| "conflict";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
|
||||
);
|
||||
};
|
||||
|
||||
const conflictResponse = (
|
||||
message: string,
|
||||
details?: { [key: string]: string },
|
||||
cors: boolean = false,
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "conflict",
|
||||
message,
|
||||
details: details || {},
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 409,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const tooManyRequestsResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
@@ -270,4 +295,5 @@ export const responses = {
|
||||
successResponse,
|
||||
tooManyRequestsResponse,
|
||||
forbiddenResponse,
|
||||
conflictResponse,
|
||||
};
|
||||
|
||||
@@ -98,14 +98,11 @@ describe("Users Lib", () => {
|
||||
|
||||
test("returns conflict error if user with email already exists", async () => {
|
||||
(prisma.user.create as any).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed on the fields: (`email`)",
|
||||
{
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
meta: { target: ["email"] },
|
||||
}
|
||||
)
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
meta: { target: ["email"] },
|
||||
})
|
||||
);
|
||||
const result = await createUser(
|
||||
{ name: "Duplicate", email: "test@example.com", role: "member" },
|
||||
|
||||
@@ -46,9 +46,9 @@ export const OpenIdButton = ({
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center">
|
||||
{text ? text : t("auth.continue_with_openid")}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
className="w-full items-center justify-center gap-2 px-2">
|
||||
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
|
||||
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -403,32 +403,6 @@ describe("handleSsoCallback", () => {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const githubAccount = { ...mockAccount, provider: "github" };
|
||||
|
||||
await expect(
|
||||
handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: githubAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
expect(upsertAccount).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject legacy google users when the provider account id does not match the stored legacy link", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||
id: "existing-user-id",
|
||||
email: mockUser.email,
|
||||
emailVerified: new Date(),
|
||||
identityProvider: "google",
|
||||
locale: mockUser.locale,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleSsoCallback({
|
||||
user: mockUser,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
|
||||
type SkeletonLoaderProps = {
|
||||
type: "response" | "summary";
|
||||
type: "response" | "responseTable" | "summary";
|
||||
};
|
||||
|
||||
export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
@@ -25,6 +25,43 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "responseTable") {
|
||||
const renderTableCells = () => (
|
||||
<>
|
||||
<Skeleton className="h-4 w-4 rounded-xl bg-slate-400" />
|
||||
<Skeleton className="h-4 w-24 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-pulse space-y-4" data-testid="skeleton-loader-response-table">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-md bg-slate-300" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
|
||||
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||
<div className="flex h-12 items-center gap-4 border-b border-slate-200 bg-slate-100 px-4">
|
||||
{renderTableCells()}
|
||||
</div>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-12 items-center gap-4 border-b border-slate-100 px-4 last:border-b-0">
|
||||
{renderTableCells()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "response") {
|
||||
return (
|
||||
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">
|
||||
|
||||
16
docs/formbricks.js
Normal file
16
docs/formbricks.js
Normal file
@@ -0,0 +1,16 @@
|
||||
!function () {
|
||||
var appUrl = "https://app.formbricks.com"; // use PUBLIC_URL if you are using multi-domain setup, otherwise use WEBAPP_URL
|
||||
var environmentId = "clgwcwp4z000lpf0hur7pzbuv";
|
||||
|
||||
var t = document.createElement("script");
|
||||
t.type = "text/javascript";
|
||||
t.async = !0;
|
||||
t.src = appUrl + "/js/formbricks.umd.cjs";
|
||||
|
||||
var e = document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t, e);
|
||||
|
||||
setTimeout(function () {
|
||||
window.formbricks.setup({ environmentId: environmentId, appUrl: appUrl });
|
||||
}, 500);
|
||||
}();
|
||||
@@ -1,5 +1,6 @@
|
||||
// basic regex -- [whitespace](number)(rem)[whitespace or ;]
|
||||
const REM_REGEX = /\b(\d+(\.\d+)?)(rem)\b/gi;
|
||||
// Matches a CSS numeric value followed by "rem" — e.g. "1rem", "1.5rem", "16rem".
|
||||
// Single character-class + single quantifier: no nested quantifiers, no backtracking risk.
|
||||
const REM_REGEX = /([\d.]+)(rem)/gi; // NOSONAR -- single character-class quantifier on trusted CSS input; no backtracking risk
|
||||
const PROCESSED = Symbol("processed");
|
||||
|
||||
const remtoEm = (opts = {}) => {
|
||||
@@ -26,6 +27,36 @@ const remtoEm = (opts = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm()],
|
||||
// Strips the `@layer properties { ... }` block that Tailwind v4 emits as a
|
||||
// browser-compatibility fallback for `@property` declarations.
|
||||
//
|
||||
// Problem: CSS `@layer` at-rules are globally scoped by spec — they cannot be
|
||||
// confined by a surrounding selector. Even though all other Formbricks survey
|
||||
// styles are correctly scoped to `#fbjs`, the `@layer properties` block
|
||||
// contains a bare `*, :before, :after, ::backdrop` selector that resets all
|
||||
// `--tw-*` CSS custom properties on every element of the host page. This
|
||||
// breaks shadows, rings, transforms, and other Tailwind utilities on any site
|
||||
// that uses Tailwind v4 alongside the Formbricks SDK.
|
||||
//
|
||||
// The `@property` declarations already present in the same stylesheet cover
|
||||
// the same browser-compatibility need for all supporting browsers, so removing
|
||||
// `@layer properties` does not affect survey rendering.
|
||||
//
|
||||
// See: https://github.com/formbricks/js/issues/46
|
||||
const stripLayerProperties = () => {
|
||||
return {
|
||||
postcssPlugin: "postcss-strip-layer-properties",
|
||||
AtRule: {
|
||||
layer: (atRule) => {
|
||||
if (atRule.params === "properties") {
|
||||
atRule.remove();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
stripLayerProperties.postcss = true;
|
||||
|
||||
module.exports = {
|
||||
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties()],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user