Compare commits

...

9 Commits

Author SHA1 Message Date
victorvhs017 2c7f92a4d7 feat: user endpoints (#5232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 06:06:18 +00:00
Piyush Gupta c653841037 chore: block signin with SSO when user is not found (#5233)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 04:22:53 +00:00
Matti Nannt ec314c14ea fix: failing e2e test (#5234) 2025-04-05 14:20:22 +02:00
victorvhs017 c03e60ac0b feat: organization endpoints (#5076)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-05 13:54:21 +02:00
Dhruwang Jariwala cbf2343143 feat: lastLoginAt to user model (#5216) 2025-04-05 13:22:38 +02:00
Dhruwang Jariwala 9d9b3ac543 chore: added isActive to user model (#5211)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-05 12:22:45 +02:00
Matti Nannt 591b35a70b fix: upgrade npm dependencies with high security risk (#5221) 2025-04-05 06:04:01 +02:00
Piyush Gupta f0c7b881d3 fix: don't allow spaces as "other" values in select questions (#5224) 2025-04-04 08:01:26 +00:00
dependabot[bot] 3fd5515db1 chore(deps): bump SonarSource/sonarqube-scan-action from 4.2.1 to 5.1.0 (#5104)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 05:03:40 +02:00
143 changed files with 6962 additions and 711 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
run: |
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1 -1
View File
@@ -18,7 +18,7 @@
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.6",
"react-native": "0.78.2",
"react-native-webview": "13.12.5"
},
"devDependencies": {
+6
View File
@@ -1,6 +1,7 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
@@ -13,6 +14,11 @@ const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null;
// If user account is deactivated, log them out instead of rendering the app
if (user?.isActive === false) {
return <ClientLogout />;
}
return (
<>
<NoMobileOverlay />
+36 -5
View File
@@ -62,9 +62,27 @@ describe("getApiKeyWithPermissions", () => {
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{ environmentId: "env-1", permission: "manage" },
{ environmentId: "env-2", permission: "write" },
{ environmentId: "env-3", permission: "read" },
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
},
];
it("should return true for manage permission with any method", () => {
@@ -108,7 +126,12 @@ describe("authenticateRequest", () => {
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
environment: {
id: "env-1",
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
},
},
],
};
@@ -121,7 +144,15 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [{ environmentId: "env-1", permission: "manage" }],
environmentPermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
+4
View File
@@ -21,11 +21,15 @@ export const authenticateRequest = async (request: Request): Promise<TAuthentica
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return authentication;
@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };
+3
View File
@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };
@@ -0,0 +1,3 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };
@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };
@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
export { GET, POST };
@@ -0,0 +1,3 @@
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
export { GET, POST, PATCH };
+3
View File
@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/roles/route";
export { GET };
@@ -11,6 +11,7 @@ export const authenticateRequest = async (
if (!apiKey) return err({ type: "unauthorized" });
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return err({ type: "unauthorized" });
const hashedApiKey = hashApiKey(apiKey);
@@ -19,11 +20,15 @@ export const authenticateRequest = async (
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return ok(authentication);
};
@@ -1,7 +1,7 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
@@ -34,12 +34,22 @@ describe("authenticateRequest", () => {
{
environmentId: "env-id-1",
permission: "manage",
environment: { id: "env-id-1" },
environment: {
id: "env-id-1",
projectId: "project-id-1",
type: "development",
project: { name: "Project 1" },
},
},
{
environmentId: "env-id-2",
permission: "read",
environment: { id: "env-id-2" },
environment: {
id: "env-id-2",
projectId: "project-id-2",
type: "production",
project: { name: "Project 2" },
},
},
],
};
@@ -55,8 +65,20 @@ describe("authenticateRequest", () => {
expect(result.data).toEqual({
type: "apiKey",
environmentPermissions: [
{ environmentId: "env-id-1", permission: "manage" },
{ environmentId: "env-id-2", permission: "read" },
{
environmentId: "env-id-1",
permission: "manage",
environmentType: "development",
projectId: "project-id-1",
projectName: "Project 1",
},
{
environmentId: "env-id-2",
permission: "read",
environmentType: "production",
projectId: "project-id-2",
projectName: "Project 2",
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
+4 -1
View File
@@ -122,9 +122,11 @@ const notFoundResponse = ({
const conflictResponse = ({
cors = false,
cache = "private, no-store",
details = [],
}: {
cors?: boolean;
cache?: string;
details?: ApiErrorDetails;
} = {}) => {
const headers = {
...(cors && corsHeaders),
@@ -136,6 +138,7 @@ const conflictResponse = ({
error: {
code: 409,
message: "Conflict",
details,
},
},
{
@@ -232,7 +235,7 @@ const internalServerErrorResponse = ({
const successResponse = ({
data,
meta,
cors = false,
cors = true,
cache = "private, no-store",
}: {
data: Object;
@@ -85,13 +85,15 @@ describe("API Responses", () => {
describe("conflictResponse", () => {
test("return a 409 response", async () => {
const res = responses.conflictResponse();
const details = [{ field: "resource", issue: "already exists" }];
const res = responses.conflictResponse({ details });
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toEqual({
error: {
code: 409,
message: "Conflict",
details,
},
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
case "not_found":
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse();
return responses.conflictResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
@@ -9,7 +9,12 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
return { limit, skip, sortBy, order, startDate, endDate };
}
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
type HasFindMany =
| Prisma.WebhookFindManyArgs
| Prisma.ResponseFindManyArgs
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
@@ -1,4 +1,4 @@
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
@@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
description: "Gets a response from the database.",
requestParams: {
path: z.object({
id: responseIdSchema,
id: ZResponseIdSchema,
}),
},
tags: ["Management API > Responses"],
@@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: responseIdSchema,
id: ZResponseIdSchema,
}),
},
responses: {
@@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: responseIdSchema,
id: ZResponseIdSchema,
}),
},
requestBody: {
@@ -1,7 +1,7 @@
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
@@ -98,7 +98,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
export const updateResponse = async (
responseId: string,
responseInput: z.infer<typeof responseUpdateSchema>
responseInput: z.infer<typeof ZResponseUpdateSchema>
): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const updatedResponse = await prisma.response.update({
@@ -1,6 +1,6 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,
@@ -9,13 +9,13 @@ import {
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { z } from "zod";
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: responseIdSchema }),
params: z.object({ responseId: ZResponseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -52,7 +52,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: responseIdSchema }),
params: z.object({ responseId: ZResponseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -91,8 +91,8 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
externalParams: props.params,
schemas: {
params: z.object({ responseId: responseIdSchema }),
body: responseUpdateSchema,
params: z.object({ responseId: ZResponseIdSchema }),
body: ZResponseUpdateSchema,
},
handler: async ({ authentication, parsedInput }) => {
const { body, params } = parsedInput;
@@ -4,7 +4,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const responseIdSchema = z
export const ZResponseIdSchema = z
.string()
.cuid2()
.openapi({
@@ -16,7 +16,7 @@ export const responseIdSchema = z
},
});
export const responseUpdateSchema = ZResponse.omit({
export const ZResponseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).openapi({
@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
@@ -14,7 +13,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
summary: "Get responses",
description: "Gets responses from the database.",
requestParams: {
query: ZGetResponsesFilter.sourceType().required(),
query: ZGetResponsesFilter.sourceType(),
},
tags: ["Management API > Responses"],
responses: {
@@ -22,7 +21,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
description: "Responses retrieved successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
schema: responseWithMetaSchema(makePartialSchema(ZResponse)),
},
},
},
@@ -134,12 +134,14 @@ export const getResponses = async (
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const query = getResponsesQuery(environmentIds, params);
const [responses, count] = await prisma.$transaction([
prisma.response.findMany({
...getResponsesQuery(environmentIds, params),
...query,
}),
prisma.response.count({
where: getResponsesQuery(environmentIds, params).where,
where: query.where,
}),
]);
@@ -1,6 +1,6 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -81,6 +81,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error);
}
return responses.successResponse({ data: createResponseResult.data, cors: true });
return responses.successResponse({ data: createResponseResult.data });
},
});
@@ -1,26 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponse } from "@/modules/api/v2/types/api-success";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getRoles = async (): Promise<Result<ApiResponse<string[]>, ApiErrorResponseV2>> => {
try {
// We use a raw query to get all the roles because we can't list enum options with prisma
const results = await prisma.$queryRaw<{ unnest: string }[]>`
SELECT unnest(enum_range(NULL::"OrganizationRole"));
`;
if (!results) {
// We set internal_server_error because it's an enum and we should always have the roles
return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
}
const roles = results.map((row) => row.unnest);
return ok({
data: roles,
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
}
};
@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getRoles } from "../roles";
// Mock prisma with a $queryRaw function
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
describe("getRoles", () => {
it("returns roles on success", async () => {
(prisma.$queryRaw as any).mockResolvedValueOnce([{ unnest: "ADMIN" }, { unnest: "MEMBER" }]);
const result = await getRoles();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(["ADMIN", "MEMBER"]);
}
});
it("returns error if no results are found", async () => {
(prisma.$queryRaw as any).mockResolvedValueOnce(null);
const result = await getRoles();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
it("returns error on exception", async () => {
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Test DB error"));
const result = await getRoles();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
@@ -1,6 +1,6 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
@@ -1,4 +1,4 @@
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ZWebhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
@@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
description: "Gets a webhook from the database.",
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
id: ZWebhookIdSchema,
}),
},
tags: ["Management API > Webhooks"],
@@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
id: ZWebhookIdSchema,
}),
},
responses: {
@@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
id: ZWebhookIdSchema,
}),
},
requestBody: {
@@ -3,7 +3,7 @@ import {
mockedPrismaWebhookUpdateReturn,
prismaNotFoundError,
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -61,7 +61,7 @@ describe("getWebhook", () => {
});
describe("updateWebhook", () => {
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof ZWebhookUpdateSchema>;
test("returns ok on successful update", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
@@ -1,5 +1,5 @@
import { webhookCache } from "@/lib/cache/webhook";
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Webhook } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
@@ -42,7 +42,7 @@ export const getWebhook = async (webhookId: string) =>
export const updateWebhook = async (
webhookId: string,
webhookInput: z.infer<typeof webhookUpdateSchema>
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
try {
const updatedWebhook = await prisma.webhook.update({
@@ -1,6 +1,6 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
import {
deleteWebhook,
@@ -8,8 +8,8 @@ import {
updateWebhook,
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
import {
webhookIdSchema,
webhookUpdateSchema,
ZWebhookIdSchema,
ZWebhookUpdateSchema,
} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
@@ -19,7 +19,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho
authenticatedApiClient({
request,
schemas: {
params: z.object({ webhookId: webhookIdSchema }),
params: z.object({ webhookId: ZWebhookIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -53,8 +53,8 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
authenticatedApiClient({
request,
schemas: {
params: z.object({ webhookId: webhookIdSchema }),
body: webhookUpdateSchema,
params: z.object({ webhookId: ZWebhookIdSchema }),
body: ZWebhookUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -112,7 +112,7 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
authenticatedApiClient({
request,
schemas: {
params: z.object({ webhookId: webhookIdSchema }),
params: z.object({ webhookId: ZWebhookIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -4,7 +4,7 @@ import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
export const webhookIdSchema = z
export const ZWebhookIdSchema = z
.string()
.cuid2()
.openapi({
@@ -16,7 +16,7 @@ export const webhookIdSchema = z
},
});
export const webhookUpdateSchema = ZWebhook.omit({
export const ZWebhookUpdateSchema = ZWebhook.omit({
id: true,
createdAt: true,
updatedAt: true,
@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
@@ -14,7 +13,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
summary: "Get webhooks",
description: "Gets webhooks from the database.",
requestParams: {
query: ZGetWebhooksFilter.sourceType().required(),
query: ZGetWebhooksFilter.sourceType(),
},
tags: ["Management API > Webhooks"],
responses: {
@@ -22,7 +21,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
description: "Webhooks retrieved successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
schema: responseWithMetaSchema(makePartialSchema(ZWebhook)),
},
},
},
@@ -60,7 +59,7 @@ export const webhookPaths: ZodOpenApiPathsObject = {
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/webhooks/{webhookId}": {
"/webhooks/{id}": {
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,
@@ -13,12 +13,14 @@ export const getWebhooks = async (
params: TGetWebhooksFilter
): Promise<Result<ApiResponseWithMeta<Webhook[]>, ApiErrorResponseV2>> => {
try {
const query = getWebhooksQuery(environmentIds, params);
const [webhooks, count] = await prisma.$transaction([
prisma.webhook.findMany({
...getWebhooksQuery(environmentIds, params),
...query,
}),
prisma.webhook.count({
where: getWebhooksQuery(environmentIds, params).where,
where: query.where,
}),
]);
@@ -1,6 +1,6 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook";
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
+26
View File
@@ -0,0 +1,26 @@
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
export const getMeEndpoint: ZodOpenApiOperationObject = {
operationId: "me",
summary: "Me",
description: "Fetches the projects and organizations associated with the API key.",
tags: ["Me"],
responses: {
"200": {
description: "API key information retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZApiKeyData),
},
},
},
},
};
export const mePaths: ZodOpenApiPathsObject = {
"/me": {
get: getMeEndpoint,
},
};
+32
View File
@@ -0,0 +1,32 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { NextRequest } from "next/server";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
handler: async ({ authentication }) => {
if (!authentication.organizationAccess?.accessControl?.[OrganizationAccessType.Read]) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
return responses.successResponse({
data: {
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
environmentId: permission.environmentId,
environmentType: permission.environmentType,
permissions: permission.permission,
projectId: permission.projectId,
projectName: permission.projectName,
})),
organizationId: authentication.organizationId,
organizationAccess: authentication.organizationAccess,
},
});
},
});
+38 -5
View File
@@ -2,18 +2,27 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { rolePaths } from "@/modules/api/v2/management/roles/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
import { mePaths } from "@/modules/api/v2/me/lib/openapi";
import { projectTeamPaths } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi";
import { teamPaths } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/openapi";
import { userPaths } from "@/modules/api/v2/organizations/[organizationId]/users/lib/openapi";
import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
import { ZContact } from "@formbricks/database/zod/contact";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
import { ZResponse } from "@formbricks/database/zod/responses";
import { ZRoles } from "@formbricks/database/zod/roles";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
import { ZTeam } from "@formbricks/database/zod/teams";
import { ZUser } from "@formbricks/database/zod/users";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
@@ -26,6 +35,8 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...rolePaths,
...mePaths,
...responsePaths,
...bulkContactPaths,
...contactPaths,
@@ -33,7 +44,9 @@ const document = createDocument({
...contactAttributeKeyPaths,
...surveyPaths,
...webhookPaths,
...rolePaths,
...teamPaths,
...projectTeamPaths,
...userPaths,
},
servers: [
{
@@ -42,6 +55,14 @@ const document = createDocument({
},
],
tags: [
{
name: "Roles",
description: "Operations for managing roles.",
},
{
name: "Me",
description: "Operations for managing your API key.",
},
{
name: "Management API > Responses",
description: "Operations for managing responses.",
@@ -67,8 +88,16 @@ const document = createDocument({
description: "Operations for managing webhooks.",
},
{
name: "Management API > Roles",
description: "Operations for managing roles.",
name: "Organizations API > Teams",
description: "Operations for managing teams.",
},
{
name: "Organizations API > Project Teams",
description: "Operations for managing project teams.",
},
{
name: "Organizations API > Users",
description: "Operations for managing users.",
},
],
components: {
@@ -81,13 +110,17 @@ const document = createDocument({
},
},
schemas: {
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,
contact: ZContact,
contactAttribute: ZContactAttribute,
contactAttributeKey: ZContactAttributeKey,
survey: ZSurveyWithoutQuestionType,
webhook: ZWebhook,
role: z.array(z.string()),
team: ZTeam,
projectTeam: ZProjectTeam,
user: ZUser,
},
},
security: [
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { hasOrganizationIdAndAccess } from "./utils";
describe("hasOrganizationIdAndAccess", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("should return false and log error if authentication has no organizationId", () => {
const spyError = vi.spyOn(logger, "error").mockImplementation(() => {});
const authentication = {
organizationAccess: { accessControl: { read: true } },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(false);
expect(spyError).toHaveBeenCalledWith(
"Organization ID from params does not match the authenticated organization ID"
);
});
it("should return false and log error if param organizationId does not match authentication organizationId", () => {
const spyError = vi.spyOn(logger, "error").mockImplementation(() => {});
const authentication = {
organizationId: "org2",
organizationAccess: { accessControl: { read: true } },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(false);
expect(spyError).toHaveBeenCalledWith(
"Organization ID from params does not match the authenticated organization ID"
);
});
it("should return false if access type is missing in organizationAccess", () => {
const authentication = {
organizationId: "org1",
organizationAccess: { accessControl: {} },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(false);
});
it("should return true if organizationId and access type are valid", () => {
const authentication = {
organizationId: "org1",
organizationAccess: { accessControl: { read: true } },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export const hasOrganizationIdAndAccess = (
paramOrganizationId: string,
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType
): boolean => {
if (paramOrganizationId !== authentication.organizationId) {
logger.error("Organization ID from params does not match the authenticated organization ID");
return false;
}
if (!authentication.organizationAccess?.accessControl?.[accessType]) {
return false;
}
return true;
};
@@ -0,0 +1,129 @@
import {
ZGetProjectTeamUpdateFilter,
ZGetProjectTeamsFilter,
ZProjectTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
operationId: "getProjectTeams",
summary: "Get project teams",
description: "Gets projectTeams from the database.",
requestParams: {
query: ZGetProjectTeamsFilter.sourceType(),
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
responses: {
"200": {
description: "Project teams retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZProjectTeam)),
},
},
},
},
};
export const createProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "createProjectTeam",
summary: "Create a projectTeam",
description: "Creates a project team in the database.",
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
requestBody: {
required: true,
description: "The project team to create",
content: {
"application/json": {
schema: ZProjectTeamInput,
},
},
},
responses: {
"201": {
description: "Project team created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZProjectTeam),
},
},
},
},
};
export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteProjectTeam",
summary: "Delete a project team",
description: "Deletes a project team from the database.",
tags: ["Organizations API > Project Teams"],
requestParams: {
query: ZGetProjectTeamUpdateFilter.required(),
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
responses: {
"200": {
description: "Project team deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZProjectTeam),
},
},
},
},
};
export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateProjectTeam",
summary: "Update a project team",
description: "Updates a project team in the database.",
tags: ["Organizations API > Project Teams"],
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
requestBody: {
required: true,
description: "The project team to update",
content: {
"application/json": {
schema: ZProjectTeamInput,
},
},
},
responses: {
"200": {
description: "Project team updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZProjectTeam),
},
},
},
},
};
export const projectTeamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/project-teams": {
servers: organizationServer,
get: getProjectTeamsEndpoint,
post: createProjectTeamEndpoint,
put: updateProjectTeamEndpoint,
delete: deleteProjectTeamEndpoint,
},
};
@@ -0,0 +1,132 @@
import { teamCache } from "@/lib/cache/team";
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import {
TGetProjectTeamsFilter,
TProjectTeamInput,
ZProjectZTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { ProjectTeam } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { projectCache } from "@formbricks/lib/project/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getProjectTeams = async (
organizationId: string,
params: TGetProjectTeamsFilter
): Promise<Result<ApiResponseWithMeta<ProjectTeam[]>, ApiErrorResponseV2>> => {
try {
const query = getProjectTeamsQuery(organizationId, params);
const [projectTeams, count] = await prisma.$transaction([
prisma.projectTeam.findMany({
...query,
}),
prisma.projectTeam.count({
where: query.where,
}),
]);
return ok({
data: projectTeams,
meta: {
total: count,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] });
}
};
export const createProjectTeam = async (
teamInput: TProjectTeamInput
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
captureTelemetry("project team created");
const { teamId, projectId, permission } = teamInput;
try {
const projectTeam = await prisma.projectTeam.create({
data: {
teamId,
projectId,
permission,
},
});
projectCache.revalidate({
id: projectId,
});
teamCache.revalidate({
id: teamId,
});
return ok(projectTeam);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] });
}
};
export const updateProjectTeam = async (
teamId: string,
projectId: string,
teamInput: z.infer<typeof ZProjectZTeamUpdateSchema>
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
try {
const updatedProjectTeam = await prisma.projectTeam.update({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
data: teamInput,
});
projectCache.revalidate({
id: projectId,
});
teamCache.revalidate({
id: teamId,
});
return ok(updatedProjectTeam);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] });
}
};
export const deleteProjectTeam = async (
teamId: string,
projectId: string
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
try {
const deletedProjectTeam = await prisma.projectTeam.delete({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
projectCache.revalidate({
id: projectId,
});
teamCache.revalidate({
id: teamId,
});
return ok(deletedProjectTeam);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] });
}
};
@@ -0,0 +1,134 @@
import {
TGetProjectTeamsFilter,
TProjectTeamInput,
ZProjectZTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TypeOf } from "zod";
import { prisma } from "@formbricks/database";
import { createProjectTeam, deleteProjectTeam, getProjectTeams, updateProjectTeam } from "../project-teams";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$transaction: vi.fn(),
},
}));
describe("ProjectTeams Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getProjectTeams", () => {
it("returns projectTeams with meta on success", async () => {
const mockTeams = [{ id: "projTeam1", organizationId: "orgx", projectId: "p1", teamId: "t1" }];
(prisma.$transaction as any).mockResolvedValueOnce([mockTeams, mockTeams.length]);
const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(mockTeams);
expect(result.data.meta).not.toBeNull();
if (result.data.meta) {
expect(result.data.meta.total).toBe(mockTeams.length);
}
}
});
it("returns internal_server_error on exception", async () => {
(prisma.$transaction as any).mockRejectedValueOnce(new Error("DB error"));
const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("createProjectTeam", () => {
it("creates a projectTeam successfully", async () => {
const mockCreated = { id: "ptx", projectId: "p1", teamId: "t1", organizationId: "orgx" };
(prisma.projectTeam.create as any).mockResolvedValueOnce(mockCreated);
const result = await createProjectTeam({
projectId: "p1",
teamId: "t1",
} as TProjectTeamInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect((result.data as any).id).toBe("ptx");
}
});
it("returns internal_server_error on error", async () => {
(prisma.projectTeam.create as any).mockRejectedValueOnce(new Error("Create error"));
const result = await createProjectTeam({
projectId: "p1",
teamId: "t1",
} as TProjectTeamInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateProjectTeam", () => {
it("updates a projectTeam successfully", async () => {
(prisma.projectTeam.update as any).mockResolvedValueOnce({
id: "pt01",
projectId: "p1",
teamId: "t1",
permission: "READ",
});
const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf<
typeof ZProjectZTeamUpdateSchema
>);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.permission).toBe("READ");
}
});
it("returns internal_server_error on error", async () => {
(prisma.projectTeam.update as any).mockRejectedValueOnce(new Error("Update error"));
const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf<
typeof ZProjectZTeamUpdateSchema
>);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("deleteProjectTeam", () => {
it("deletes a projectTeam successfully", async () => {
(prisma.projectTeam.delete as any).mockResolvedValueOnce({
projectId: "p1",
teamId: "t1",
permission: "READ",
});
const result = await deleteProjectTeam("t1", "p1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.projectId).toBe("p1");
expect(result.data.teamId).toBe("t1");
}
});
it("returns internal_server_error on error", async () => {
(prisma.projectTeam.delete as any).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteProjectTeam("t1", "p1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});
@@ -0,0 +1,113 @@
import { teamCache } from "@/lib/cache/team";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetProjectTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getProjectTeamsQuery = (organizationId: string, params: TGetProjectTeamsFilter) => {
const { teamId, projectId } = params || {};
let query: Prisma.ProjectTeamFindManyArgs = {
where: {
team: {
organizationId,
},
},
};
if (teamId) {
query = {
...query,
where: {
...query.where,
teamId,
},
};
}
if (projectId) {
query = {
...query,
where: {
...query.where,
projectId,
project: {
organizationId,
},
},
};
}
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ProjectTeamFindManyArgs>(query, baseFilter);
}
return query;
};
export const validateTeamIdAndProjectId = reactCache(
async (organizationId: string, teamId: string, projectId: string) =>
cache(
async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const hasAccess = await prisma.organization.findFirst({
where: {
id: organizationId,
teams: {
some: {
id: teamId,
},
},
projects: {
some: {
id: projectId,
},
},
},
});
if (!hasAccess) {
return err({ type: "not_found", details: [{ field: "teamId/projectId", issue: "not_found" }] });
}
return ok(true);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "teamId/projectId", issue: error.message }],
});
}
},
[`validateTeamIdAndProjectId-${organizationId}-${teamId}-${projectId}`],
{
tags: [
teamCache.tag.byId(teamId),
projectCache.tag.byId(projectId),
organizationCache.tag.byId(organizationId),
],
}
)()
);
export const checkAuthenticationAndAccess = async (
teamId: string,
projectId: string,
authentication: TAuthenticationApiKey
): Promise<Result<boolean, ApiErrorResponseV2>> => {
const hasAccess = await validateTeamIdAndProjectId(authentication.organizationId, teamId, projectId);
if (!hasAccess.ok) {
return err(hasAccess.error);
}
return ok(true);
};
@@ -0,0 +1,168 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { checkAuthenticationAndAccess } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { z } from "zod";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import {
createProjectTeam,
deleteProjectTeam,
getProjectTeams,
updateProjectTeam,
} from "./lib/project-teams";
import {
ZGetProjectTeamUpdateFilter,
ZGetProjectTeamsFilter,
ZProjectTeamInput,
} from "./types/project-teams";
export async function GET(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
query: ZGetProjectTeamsFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, params }, authentication }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const result = await getProjectTeams(authentication.organizationId, query!);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse(result.data);
},
});
}
export async function POST(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
body: ZProjectTeamInput,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication }) => {
const { teamId, projectId } = body!;
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
}
// check if project team already exists
const existingProjectTeam = await getProjectTeams(authentication.organizationId, {
teamId,
projectId,
limit: 10,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (!existingProjectTeam.ok) {
return handleApiError(request, existingProjectTeam.error);
}
if (existingProjectTeam.data.data.length > 0) {
return handleApiError(request, {
type: "conflict",
details: [{ field: "projectTeam", issue: "Project team already exists" }],
});
}
const result = await createProjectTeam(body!);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data });
},
});
}
export async function PUT(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
body: ZProjectTeamInput,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication }) => {
const { teamId, projectId } = body!;
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
}
const result = await updateProjectTeam(teamId, projectId, body!);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data });
},
});
}
export async function DELETE(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
query: ZGetProjectTeamUpdateFilter,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, params }, authentication }) => {
const { teamId, projectId } = query!;
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
}
const result = await deleteProjectTeam(teamId, projectId);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data });
},
});
}
@@ -0,0 +1,37 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
export const ZGetProjectTeamsFilter = ZGetFilter.extend({
teamId: z.string().cuid2().optional(),
projectId: z.string().cuid2().optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetProjectTeamsFilter = z.infer<typeof ZGetProjectTeamsFilter>;
export const ZProjectTeamInput = ZProjectTeam.pick({
teamId: true,
projectId: true,
permission: true,
});
export type TProjectTeamInput = z.infer<typeof ZProjectTeamInput>;
export const ZGetProjectTeamUpdateFilter = z.object({
teamId: z.string().cuid2(),
projectId: z.string().cuid2(),
});
export const ZProjectZTeamUpdateSchema = ZProjectTeam.pick({
permission: true,
});
@@ -0,0 +1,85 @@
import { ZTeamIdSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ZTeamInput } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
export const getTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "getTeam",
summary: "Get a team",
description: "Gets a team from the database.",
requestParams: {
path: z.object({
id: ZTeamIdSchema,
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
responses: {
"200": {
description: "Team retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
export const deleteTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteTeam",
summary: "Delete a team",
description: "Deletes a team from the database.",
tags: ["Organizations API > Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,
organizationId: ZOrganizationIdSchema,
}),
},
responses: {
"200": {
description: "Team deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
export const updateTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateTeam",
summary: "Update a team",
description: "Updates a team in the database.",
tags: ["Organizations API > Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,
organizationId: ZOrganizationIdSchema,
}),
},
requestBody: {
required: true,
description: "The team to update",
content: {
"application/json": {
schema: ZTeamInput,
},
},
},
responses: {
"200": {
description: "Team updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
@@ -0,0 +1,141 @@
import { organizationCache } from "@/lib/cache/organization";
import { teamCache } from "@/lib/cache/team";
import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Team } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getTeam = reactCache(async (organizationId: string, teamId: string) =>
cache(
async (): Promise<Result<Team, ApiErrorResponseV2>> => {
try {
const responsePrisma = await prisma.team.findUnique({
where: {
id: teamId,
organizationId,
},
});
if (!responsePrisma) {
return err({ type: "not_found", details: [{ field: "team", issue: "not found" }] });
}
return ok(responsePrisma);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "team", issue: error.message }],
});
}
},
[`organizationId-${organizationId}-getTeam-${teamId}`],
{
tags: [teamCache.tag.byId(teamId), organizationCache.tag.byId(organizationId)],
}
)()
);
export const deleteTeam = async (
organizationId: string,
teamId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
try {
const deletedTeam = await prisma.team.delete({
where: {
id: teamId,
organizationId,
},
include: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
teamCache.revalidate({
id: deletedTeam.id,
organizationId: deletedTeam.organizationId,
});
for (const projectTeam of deletedTeam.projectTeams) {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
}
return ok(deletedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "team", issue: error.message }],
});
}
};
export const updateTeam = async (
organizationId: string,
teamId: string,
teamInput: z.infer<typeof ZTeamUpdateSchema>
): Promise<Result<Team, ApiErrorResponseV2>> => {
try {
const updatedTeam = await prisma.team.update({
where: {
id: teamId,
organizationId,
},
data: teamInput,
include: {
projectTeams: { select: { projectId: true } },
},
});
teamCache.revalidate({
id: updatedTeam.id,
organizationId: updatedTeam.organizationId,
});
for (const projectTeam of updatedTeam.projectTeams) {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
}
return ok(updatedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "team", issue: error.message }],
});
}
};
@@ -0,0 +1,166 @@
import { teamCache } from "@/lib/cache/team";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { deleteTeam, getTeam, updateTeam } from "../teams";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// Define a mock team
const mockTeam = {
id: "team123",
organizationId: "org456",
name: "Test Team",
projectTeams: [{ projectId: "proj1" }, { projectId: "proj2" }],
};
describe("Teams Lib", () => {
describe("getTeam", () => {
it("returns the team when found", async () => {
(prisma.team.findUnique as any).mockResolvedValueOnce(mockTeam);
const result = await getTeam("org456", "team123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockTeam);
}
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: { id: "team123", organizationId: "org456" },
});
});
it("returns a not_found error when team is missing", async () => {
(prisma.team.findUnique as any).mockResolvedValueOnce(null);
const result = await getTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
});
it("returns an internal_server_error when prisma throws", async () => {
(prisma.team.findUnique as any).mockRejectedValueOnce(new Error("DB error"));
const result = await getTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("deleteTeam", () => {
it("deletes the team and revalidates cache", async () => {
(prisma.team.delete as any).mockResolvedValueOnce(mockTeam);
// Mock teamCache.revalidate
const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
const result = await deleteTeam("org456", "team123");
expect(prisma.team.delete).toHaveBeenCalledWith({
where: { id: "team123", organizationId: "org456" },
include: { projectTeams: { select: { projectId: true } } },
});
expect(revalidateMock).toHaveBeenCalledWith({
id: mockTeam.id,
organizationId: mockTeam.organizationId,
});
for (const pt of mockTeam.projectTeams) {
expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId });
}
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockTeam);
}
});
it("returns not_found error on known prisma error", async () => {
(prisma.team.delete as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},
})
);
const result = await deleteTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
});
it("returns internal_server_error on exception", async () => {
(prisma.team.delete as any).mockRejectedValueOnce(new Error("Delete failed"));
const result = await deleteTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateTeam", () => {
const updateInput = { name: "Updated Team" };
const updatedTeam = { ...mockTeam, ...updateInput };
it("updates the team successfully and revalidates cache", async () => {
(prisma.team.update as any).mockResolvedValueOnce(updatedTeam);
const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
const result = await updateTeam("org456", "team123", updateInput);
expect(prisma.team.update).toHaveBeenCalledWith({
where: { id: "team123", organizationId: "org456" },
data: updateInput,
include: { projectTeams: { select: { projectId: true } } },
});
expect(revalidateMock).toHaveBeenCalledWith({
id: updatedTeam.id,
organizationId: updatedTeam.organizationId,
});
for (const pt of updatedTeam.projectTeams) {
expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId });
}
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(updatedTeam);
}
});
it("returns not_found error when update fails due to missing team", async () => {
(prisma.team.update as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},
})
);
const result = await updateTeam("org456", "team123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
});
it("returns internal_server_error on generic exception", async () => {
(prisma.team.update as any).mockRejectedValueOnce(new Error("Update failed"));
const result = await updateTeam("org456", "team123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});
@@ -0,0 +1,100 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import {
deleteTeam,
getTeam,
updateTeam,
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams";
import {
ZTeamIdSchema,
ZTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { z } from "zod";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (
request: Request,
props: { params: Promise<{ teamId: string; organizationId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await getTeam(params!.organizationId, params!.teamId);
if (!team.ok) {
return handleApiError(request, team.error);
}
return responses.successResponse(team);
},
});
export const DELETE = async (
request: Request,
props: { params: Promise<{ teamId: string; organizationId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await deleteTeam(params!.organizationId, params!.teamId);
if (!team.ok) {
return handleApiError(request, team.error);
}
return responses.successResponse(team);
},
});
export const PUT = (
request: Request,
props: { params: Promise<{ teamId: string; organizationId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
body: ZTeamUpdateSchema,
},
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await updateTeam(params!.organizationId, params!.teamId, body!);
if (!team.ok) {
return handleApiError(request, team.error);
}
return responses.successResponse(team);
},
});
@@ -0,0 +1,24 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
extendZodWithOpenApi(z);
export const ZTeamIdSchema = z
.string()
.cuid2()
.openapi({
ref: "teamId",
description: "The ID of the team",
param: {
name: "id",
in: "path",
},
});
export const ZTeamUpdateSchema = ZTeam.omit({
id: true,
createdAt: true,
updatedAt: true,
organizationId: true,
});
@@ -0,0 +1,83 @@
import {
deleteTeamEndpoint,
getTeamEndpoint,
updateTeamEndpoint,
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi";
import {
ZGetTeamsFilter,
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
export const getTeamsEndpoint: ZodOpenApiOperationObject = {
operationId: "getTeams",
summary: "Get teams",
description: "Gets teams from the database.",
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetTeamsFilter.sourceType(),
},
tags: ["Organizations API > Teams"],
responses: {
"200": {
description: "Teams retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZTeam)),
},
},
},
},
};
export const createTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "createTeam",
summary: "Create a team",
description: "Creates a team in the database.",
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
requestBody: {
required: true,
description: "The team to create",
content: {
"application/json": {
schema: ZTeamInput,
},
},
},
responses: {
"201": {
description: "Team created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
export const teamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/teams": {
servers: organizationServer,
get: getTeamsEndpoint,
post: createTeamEndpoint,
},
"/{organizationId}/teams/{id}": {
servers: organizationServer,
get: getTeamEndpoint,
put: updateTeamEndpoint,
delete: deleteTeamEndpoint,
},
};
@@ -0,0 +1,73 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
import {
TGetTeamsFilter,
TTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { Team } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const createTeam = async (
teamInput: TTeamInput,
organizationId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
captureTelemetry("team created");
const { name } = teamInput;
try {
const team = await prisma.team.create({
data: {
name,
organizationId,
},
});
organizationCache.revalidate({
id: organizationId,
});
teamCache.revalidate({
organizationId: organizationId,
});
return ok(team);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "team", issue: error.message }] });
}
};
export const getTeams = async (
organizationId: string,
params: TGetTeamsFilter
): Promise<Result<ApiResponseWithMeta<Team[]>, ApiErrorResponseV2>> => {
try {
const query = getTeamsQuery(organizationId, params);
const [teams, count] = await prisma.$transaction([
prisma.team.findMany({
...query,
}),
prisma.team.count({
where: query.where,
}),
]);
return ok({
data: teams,
meta: {
total: count,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "teams", issue: error.message }] });
}
};
@@ -0,0 +1,93 @@
import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { createTeam, getTeams } from "../teams";
// Define a mock team object
const mockTeam = {
id: "team123",
organizationId: "org456",
name: "Test Team",
};
beforeEach(() => {
vi.clearAllMocks();
});
// Mock prisma methods
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn(),
},
}));
// Mock organizationCache.revalidate
vi.spyOn(organizationCache, "revalidate").mockImplementation(() => {});
describe("Teams Lib", () => {
describe("createTeam", () => {
it("creates a team successfully and revalidates cache", async () => {
(prisma.team.create as any).mockResolvedValueOnce(mockTeam);
const teamInput = { name: "Test Team" };
const organizationId = "org456";
const result = await createTeam(teamInput, organizationId);
expect(prisma.team.create).toHaveBeenCalledWith({
data: {
name: "Test Team",
organizationId: organizationId,
},
});
expect(organizationCache.revalidate).toHaveBeenCalledWith({ id: organizationId });
expect(result.ok).toBe(true);
if (result.ok) expect(result.data).toEqual(mockTeam);
});
it("returns internal error when prisma.team.create fails", async () => {
(prisma.team.create as any).mockRejectedValueOnce(new Error("Create error"));
const teamInput = { name: "Test Team" };
const organizationId = "org456";
const result = await createTeam(teamInput, organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
describe("getTeams", () => {
const filter = { limit: 10, skip: 0 };
it("returns teams with meta on success", async () => {
const teamsArray = [mockTeam];
// Simulate prisma transaction return [teams, count]
(prisma.$transaction as any).mockResolvedValueOnce([teamsArray, teamsArray.length]);
const organizationId = "org456";
const result = await getTeams(organizationId, filter as TGetTeamsFilter);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: teamsArray,
meta: { total: teamsArray.length, limit: filter.limit, offset: filter.skip },
});
}
});
it("returns internal_server_error when prisma transaction fails", async () => {
(prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error"));
const organizationId = "org456";
const result = await getTeams(organizationId, filter as TGetTeamsFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
});
@@ -0,0 +1,43 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { Prisma } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import { getTeamsQuery } from "../utils";
// Mock the common utils functions
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
pickCommonFilter: vi.fn(),
buildCommonFilterQuery: vi.fn(),
}));
describe("getTeamsQuery", () => {
const organizationId = "org123";
it("returns base query when no params provided", () => {
const result = getTeamsQuery(organizationId);
expect(result.where).toEqual({ organizationId });
});
it("returns unchanged query if pickCommonFilter returns null/undefined", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce(null as any);
const params: any = { someParam: "test" };
const result = getTeamsQuery(organizationId, params);
expect(pickCommonFilter).toHaveBeenCalledWith(params);
// Since pickCommonFilter returns undefined, query remains as base query.
expect(result.where).toEqual({ organizationId });
});
it("calls buildCommonFilterQuery and returns updated query when base filter exists", () => {
const baseFilter = { key: "value" };
vi.mocked(pickCommonFilter).mockReturnValueOnce(baseFilter as any);
// Simulate buildCommonFilterQuery to merge base query with baseFilter
const updatedQuery = { where: { organizationId, combined: true } } as Prisma.TeamFindManyArgs;
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce(updatedQuery);
const params: any = { someParam: "test" };
const result = getTeamsQuery(organizationId, params);
expect(pickCommonFilter).toHaveBeenCalledWith(params);
expect(buildCommonFilterQuery).toHaveBeenCalledWith({ where: { organizationId } }, baseFilter);
expect(result).toEqual(updatedQuery);
});
});
@@ -0,0 +1,21 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { Prisma } from "@prisma/client";
export const getTeamsQuery = (organizationId: string, params?: TGetTeamsFilter) => {
let query: Prisma.TeamFindManyArgs = {
where: {
organizationId,
},
};
if (!params) return query;
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.TeamFindManyArgs>(query, baseFilter);
}
return query;
};
@@ -0,0 +1,64 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { createTeam, getTeams } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/teams";
import {
ZGetTeamsFilter,
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { NextRequest } from "next/server";
import { z } from "zod";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetTeamsFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { query, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const res = await getTeams(authentication.organizationId, query!);
if (!res.ok) {
return handleApiError(request, res.error);
}
return responses.successResponse(res.data);
},
});
export const POST = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
body: ZTeamInput,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const createTeamResult = await createTeam(body!, authentication.organizationId);
if (!createTeamResult.ok) {
return handleApiError(request, createTeamResult.error);
}
return responses.successResponse({ data: createTeamResult.data });
},
});
@@ -0,0 +1,23 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZTeam } from "@formbricks/database/zod/teams";
export const ZGetTeamsFilter = ZGetFilter.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetTeamsFilter = z.infer<typeof ZGetTeamsFilter>;
export const ZTeamInput = ZTeam.pick({
name: true,
});
export type TTeamInput = z.infer<typeof ZTeamInput>;
@@ -0,0 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOrganizationIdSchema = z
.string()
.cuid2()
.openapi({
ref: "organizationId",
description: "The ID of the organization",
param: {
name: "organizationId",
in: "path",
},
});
@@ -0,0 +1,105 @@
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import {
ZGetUsersFilter,
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZUser } from "@formbricks/database/zod/users";
export const getUsersEndpoint: ZodOpenApiOperationObject = {
operationId: "getUsers",
summary: "Get users",
description: `Gets users from the database.<br />Only available for self-hosted Formbricks.`,
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetUsersFilter.sourceType(),
},
tags: ["Organizations API > Users"],
responses: {
"200": {
description: "Users retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZUser)),
},
},
},
},
};
export const createUserEndpoint: ZodOpenApiOperationObject = {
operationId: "createUser",
summary: "Create a user",
description: `Create a new user in the database.<br />Only available for self-hosted Formbricks.`,
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Users"],
requestBody: {
required: true,
description: "The user to create",
content: {
"application/json": {
schema: ZUserInput,
},
},
},
responses: {
"201": {
description: "User created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZUser),
},
},
},
},
};
export const updateUserEndpoint: ZodOpenApiOperationObject = {
operationId: "updateUser",
summary: "Update a user",
description: `Updates an existing user in the database.<br />Only available for self-hosted Formbricks.`,
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Users"],
requestBody: {
required: true,
description: "The user to update",
content: {
"application/json": {
schema: ZUserInputPatch,
},
},
},
responses: {
"200": {
description: "User updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZUser),
},
},
},
},
};
export const userPaths: ZodOpenApiPathsObject = {
"/{organizationId}/users": {
servers: organizationServer,
get: getUsersEndpoint,
post: createUserEndpoint,
patch: updateUserEndpoint,
},
};
@@ -0,0 +1,195 @@
import { teamCache } from "@/lib/cache/team";
import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { membershipCache } from "@formbricks/lib/membership/cache";
import { userCache } from "@formbricks/lib/user/cache";
import { createUser, getUsers, updateUser } from "../users";
const mockUser = {
id: "user123",
email: "test@example.com",
name: "Test User",
lastLoginAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
};
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findMany: vi.fn(),
count: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
team: {
findMany: vi.fn(),
},
teamUser: {
create: vi.fn(),
delete: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.spyOn(membershipCache, "revalidate").mockImplementation(() => {});
vi.spyOn(userCache, "revalidate").mockImplementation(() => {});
vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
describe("Users Lib", () => {
describe("getUsers", () => {
it("returns users with meta on success", async () => {
const usersArray = [mockUser];
(prisma.$transaction as any).mockResolvedValueOnce([usersArray, usersArray.length]);
const result = await getUsers("org456", { limit: 10, skip: 0 } as TGetUsersFilter);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toStrictEqual([
{
id: mockUser.id,
email: mockUser.email,
name: mockUser.name,
lastLoginAt: expect.any(Date),
isActive: true,
role: mockUser.role,
teams: ["Test Team"],
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
},
]);
}
});
it("returns internal_server_error if prisma fails", async () => {
(prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error"));
const result = await getUsers("org456", { limit: 10, skip: 0 } as TGetUsersFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("createUser", () => {
it("creates user and revalidates caches", async () => {
(prisma.user.create as any).mockResolvedValueOnce(mockUser);
const result = await createUser(
{ name: "Test User", email: "test@example.com", role: "member" },
"org456"
);
expect(prisma.user.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
}
});
it("returns internal_server_error if creation fails", async () => {
(prisma.user.create as any).mockRejectedValueOnce(new Error("Create error"));
const result = await createUser({ name: "fail", email: "fail@example.com", role: "manager" }, "org456");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateUser", () => {
it("updates user and revalidates caches", async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(mockUser);
(prisma.$transaction as any).mockResolvedValueOnce([{ ...mockUser, name: "Updated User" }]);
const result = await updateUser({ email: mockUser.email, name: "Updated User" }, "org456");
expect(prisma.user.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
}
});
it("returns not_found if user doesn't exist", async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(null);
const result = await updateUser({ email: "unknown@example.com" }, "org456");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
it("returns internal_server_error if update fails", async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(mockUser);
(prisma.$transaction as any).mockRejectedValueOnce(new Error("Update error"));
const result = await updateUser({ email: mockUser.email }, "org456");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("createUser with teams", () => {
it("creates user with existing teams", async () => {
(prisma.team.findMany as any).mockResolvedValueOnce([
{ id: "team123", name: "MyTeam", projectTeams: [{ projectId: "proj789" }] },
]);
(prisma.user.create as any).mockResolvedValueOnce({
...mockUser,
teamUsers: [{ team: { id: "team123", name: "MyTeam" } }],
});
const result = await createUser(
{ name: "Test", email: "team@example.com", role: "manager", teams: ["MyTeam"], isActive: true },
"org456"
);
expect(prisma.user.create).toHaveBeenCalled();
expect(teamCache.revalidate).toHaveBeenCalled();
expect(membershipCache.revalidate).toHaveBeenCalled();
expect(result.ok).toBe(true);
});
});
describe("updateUser with team changes", () => {
it("removes a team and adds new team", async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce({
...mockUser,
teamUsers: [{ team: { id: "team123", name: "OldTeam", projectTeams: [{ projectId: "proj789" }] } }],
});
(prisma.team.findMany as any).mockResolvedValueOnce([
{ id: "team456", name: "NewTeam", projectTeams: [] },
]);
(prisma.$transaction as any).mockResolvedValueOnce([
// deleted OldTeam from user
{ team: { id: "team123", name: "OldTeam", projectTeams: [{ projectId: "proj789" }] } },
// created teamUsers for NewTeam
{
team: { id: "team456", name: "NewTeam", projectTeams: [] },
},
// updated user
{ ...mockUser, name: "Updated Name" },
]);
const result = await updateUser(
{ email: mockUser.email, name: "Updated Name", teams: ["NewTeam"] },
"org456"
);
expect(prisma.user.findUnique).toHaveBeenCalled();
expect(teamCache.revalidate).toHaveBeenCalledTimes(3);
expect(membershipCache.revalidate).toHaveBeenCalled();
expect(userCache.revalidate).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.teams).toContain("NewTeam");
expect(result.data.name).toBe("Updated Name");
}
});
});
});
@@ -0,0 +1,45 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { describe, expect, it, vi } from "vitest";
import { getUsersQuery } from "../utils";
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
pickCommonFilter: vi.fn(),
buildCommonFilterQuery: vi.fn(),
}));
describe("getUsersQuery", () => {
it("returns default query if no params are provided", () => {
const result = getUsersQuery("org123");
expect(result).toEqual({
where: {
memberships: {
some: {
organizationId: "org123",
},
},
},
});
});
it("includes email filter if email param is provided", () => {
const result = getUsersQuery("org123", { email: "test@example.com" } as TGetUsersFilter);
expect(result.where?.email).toEqual({
contains: "test@example.com",
mode: "insensitive",
});
});
it("includes id filter if id param is provided", () => {
const result = getUsersQuery("org123", { id: "user123" } as TGetUsersFilter);
expect(result.where?.id).toBe("user123");
});
it("applies baseFilter if pickCommonFilter returns something", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someField: "test" } as unknown as ReturnType<
typeof pickCommonFilter
>);
getUsersQuery("org123", {} as TGetUsersFilter);
expect(buildCommonFilterQuery).toHaveBeenCalled();
});
});
@@ -0,0 +1,387 @@
import { teamCache } from "@/lib/cache/team";
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
import {
TGetUsersFilter,
TUserInput,
TUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/database/zod/users";
import { membershipCache } from "@formbricks/lib/membership/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { userCache } from "@formbricks/lib/user/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getUsers = async (
organizationId: string,
params: TGetUsersFilter
): Promise<Result<ApiResponseWithMeta<TUser[]>, ApiErrorResponseV2>> => {
try {
const query = getUsersQuery(organizationId, params);
const [users, count] = await prisma.$transaction([
prisma.user.findMany({
...query,
include: {
teamUsers: {
include: {
team: true,
},
},
memberships: {
select: {
role: true,
organizationId: true,
},
},
},
}),
prisma.user.count({
where: query.where,
}),
]);
const returnedUsers = users.map(
(user) =>
({
id: user.id,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
email: user.email,
name: user.name,
lastLoginAt: user.lastLoginAt,
isActive: user.isActive,
role: user.memberships.filter((membership) => membership.organizationId === organizationId)[0].role,
teams: user.teamUsers.map((teamUser) => teamUser.team.name),
}) as TUser
);
return ok({
data: returnedUsers,
meta: {
total: count,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "users", issue: error.message }] });
}
};
export const createUser = async (
userInput: TUserInput,
organizationId
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user created");
const { name, email, role, teams, isActive } = userInput;
try {
const existingTeams = teams && (await getExistingTeamsFromInput(teams, organizationId));
let teamUsersToCreate;
if (existingTeams) {
teamUsersToCreate = existingTeams.map((team) => ({
role: TeamUserRole.contributor,
team: {
connect: {
id: team.id,
},
},
}));
}
const prismaData: Prisma.UserCreateInput = {
name,
email,
isActive: isActive,
memberships: {
create: {
accepted: true, // auto accept because there is no invite
role: role.toLowerCase() as OrganizationRole,
organization: {
connect: {
id: organizationId,
},
},
},
},
teamUsers:
existingTeams?.length > 0
? {
create: teamUsersToCreate,
}
: undefined,
};
const user = await prisma.user.create({
data: prismaData,
include: {
memberships: {
select: {
role: true,
organizationId: true,
},
},
},
});
existingTeams?.forEach((team) => {
teamCache.revalidate({
id: team.id,
organizationId: organizationId,
});
for (const projectTeam of team.projectTeams) {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
}
});
// revalidate membership cache
membershipCache.revalidate({
organizationId: organizationId,
userId: user.id,
});
const returnedUser = {
id: user.id,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
email: user.email,
name: user.name,
lastLoginAt: user.lastLoginAt,
isActive: user.isActive,
role: user.memberships.filter((membership) => membership.organizationId === organizationId)[0].role,
teams: existingTeams ? existingTeams.map((team) => team.name) : [],
} as TUser;
return ok(returnedUser);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "user", issue: error.message }] });
}
};
export const updateUser = async (
userInput: TUserInputPatch,
organizationId: string
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user updated");
const { name, email, role, teams, isActive } = userInput;
let existingTeams: string[] = [];
let newTeams;
try {
// First, fetch the existing user along with memberships and teamUsers.
const existingUser = await prisma.user.findUnique({
where: { email },
include: {
memberships: {
select: {
role: true,
organizationId: true,
},
},
teamUsers: {
include: {
team: true,
},
},
},
});
if (!existingUser) {
return err({
type: "not_found",
details: [{ field: "user", issue: "not found" }],
});
}
// Capture the existing team names for the user.
existingTeams = existingUser.teamUsers.map((teamUser) => teamUser.team.name);
// Build an array of operations for deleting teamUsers that are not in the input.
const deleteTeamOps = [] as Prisma.PrismaPromise<any>[];
existingUser.teamUsers.forEach((teamUser) => {
if (teams && !teams?.includes(teamUser.team.name)) {
deleteTeamOps.push(
prisma.teamUser.delete({
where: {
teamId_userId: {
teamId: teamUser.team.id,
userId: existingUser.id,
},
},
include: {
team: {
include: {
projectTeams: {
select: { projectId: true },
},
},
},
},
})
);
}
});
// Look up teams from the input that exist in this organization.
newTeams = await getExistingTeamsFromInput(teams, organizationId);
const existingUserTeamNames = existingUser.teamUsers.map((teamUser) => teamUser.team.name);
// Build an array of operations for creating new teamUsers.
const createTeamOps = [] as Prisma.PrismaPromise<any>[];
newTeams?.forEach((team) => {
if (!existingUserTeamNames.includes(team.name)) {
createTeamOps.push(
prisma.teamUser.create({
data: {
role: TeamUserRole.contributor,
user: { connect: { id: existingUser.id } },
team: { connect: { id: team.id } },
},
include: {
team: {
include: {
projectTeams: {
select: { projectId: true },
},
},
},
},
})
);
}
});
const prismaData: Prisma.UserUpdateInput = {
name: name ?? undefined,
email: email ?? undefined,
isActive: isActive ?? undefined,
memberships: {
updateMany: {
where: {
organizationId,
},
data: {
role: role ? (role.toLowerCase() as OrganizationRole) : undefined,
},
},
},
};
// Build the user update operation.
const updateUserOp = prisma.user.update({
where: { email },
data: prismaData,
include: {
memberships: {
select: { role: true, organizationId: true },
},
},
});
// Combine all operations into one transaction.
const operations = [...deleteTeamOps, ...createTeamOps, updateUserOp];
// Execute the transaction. The result will be an array with the results in the same order.
const results = await prisma.$transaction(operations);
// Retrieve the updated user result. Since the update was the last operation, it is the last item.
const updatedUser = results[results.length - 1];
// For each deletion, revalidate the corresponding team and its project caches.
for (const opResult of results.slice(0, deleteTeamOps.length)) {
const deletedTeamUser = opResult;
teamCache.revalidate({
id: deletedTeamUser.team.id,
userId: existingUser.id,
organizationId,
});
deletedTeamUser.team.projectTeams.forEach((projectTeam) => {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
});
}
// For each creation, do the same.
for (const opResult of results.slice(deleteTeamOps.length, deleteTeamOps.length + createTeamOps.length)) {
const newTeamUser = opResult;
teamCache.revalidate({
id: newTeamUser.team.id,
userId: existingUser.id,
organizationId,
});
newTeamUser.team.projectTeams.forEach((projectTeam) => {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
});
}
// Revalidate membership and user caches for the updated user.
membershipCache.revalidate({
organizationId,
userId: updatedUser.id,
});
userCache.revalidate({
id: updatedUser.id,
email: updatedUser.email,
});
const returnedUser = {
id: updatedUser.id,
createdAt: updatedUser.createdAt,
updatedAt: updatedUser.updatedAt,
email: updatedUser.email,
name: updatedUser.name,
lastLoginAt: updatedUser.lastLoginAt,
isActive: updatedUser.isActive,
role: updatedUser.memberships.find(
(m: { organizationId: string }) => m.organizationId === organizationId
)?.role,
teams: newTeams ? newTeams.map((team) => team.name) : existingTeams,
};
return ok(returnedUser);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "user", issue: error.message }],
});
}
};
const getExistingTeamsFromInput = async (userInputTeams: string[] | undefined, organizationId: string) => {
let existingTeams;
if (userInputTeams) {
existingTeams = await prisma.team.findMany({
where: {
name: { in: userInputTeams },
organizationId,
},
select: {
id: true,
name: true,
projectTeams: {
select: {
projectId: true,
},
},
},
});
}
return existingTeams;
};
@@ -0,0 +1,42 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { Prisma } from "@prisma/client";
export const getUsersQuery = (organizationId: string, params?: TGetUsersFilter) => {
let query: Prisma.UserFindManyArgs = {
where: {
memberships: {
some: {
organizationId,
},
},
},
};
if (!params) return query;
if (params.email) {
query.where = {
...query.where,
email: {
contains: params.email,
mode: "insensitive",
},
};
}
if (params.id) {
query.where = {
...query.where,
id: params.id,
};
}
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.UserFindManyArgs>(query, baseFilter);
}
return query;
};
@@ -0,0 +1,123 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import {
createUser,
getUsers,
updateUser,
} from "@/modules/api/v2/organizations/[organizationId]/users/lib/users";
import {
ZGetUsersFilter,
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { NextRequest } from "next/server";
import { z } from "zod";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetUsersFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { query, params } }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const res = await getUsers(authentication.organizationId, query!);
if (!res.ok) {
return handleApiError(request, res.error);
}
return responses.successResponse(res.data);
},
});
export const POST = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
body: ZUserInput,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const createUserResult = await createUser(body!, authentication.organizationId);
if (!createUserResult.ok) {
return handleApiError(request, createUserResult.error);
}
return responses.successResponse({ data: createUserResult.data });
},
});
export const PATCH = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
body: ZUserInputPatch,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
if (!body?.email) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "email", issue: "Email is required" }],
});
}
const updateUserResult = await updateUser(body, authentication.organizationId);
if (!updateUserResult.ok) {
return handleApiError(request, updateUserResult.error);
}
return responses.successResponse({ data: updateUserResult.data });
},
});
@@ -0,0 +1,42 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZUser } from "@formbricks/database/zod/users";
import { ZUserName } from "@formbricks/types/user";
export const ZGetUsersFilter = ZGetFilter.extend({
id: z.string().optional(),
email: z.string().optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetUsersFilter = z.infer<typeof ZGetUsersFilter>;
export const ZUserInput = ZUser.omit({
id: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
}).extend({
isActive: ZUser.shape.isActive.optional(),
});
export type TUserInput = z.infer<typeof ZUserInput>;
export const ZUserInputPatch = ZUserInput.extend({
// Override specific fields to be optional
name: ZUserName.optional(),
role: ZUser.shape.role.optional(),
teams: ZUser.shape.teams.optional(),
isActive: ZUser.shape.isActive.optional(),
});
export type TUserInputPatch = z.infer<typeof ZUserInputPatch>;
@@ -0,0 +1,6 @@
export const organizationServer = [
{
url: "https://app.formbricks.com/api/v2/organizations",
description: "Formbricks Cloud",
},
];
@@ -1,18 +1,19 @@
import { z } from "zod";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZRoles } from "@formbricks/database/zod/roles";
export const getRolesEndpoint: ZodOpenApiOperationObject = {
operationId: "getRoles",
summary: "Get roles",
description: "Gets roles from the database.",
requestParams: {},
tags: ["Management API > Roles"],
tags: ["Roles"],
responses: {
"200": {
description: "Roles retrieved successfully.",
content: {
"application/json": {
schema: z.array(z.string()),
schema: makePartialSchema(ZRoles),
},
},
},
@@ -0,0 +1,21 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { OrganizationRole } from "@prisma/client";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getRoles = (): Result<{ data: string[] }, ApiErrorResponseV2> => {
try {
const roles = Object.values(OrganizationRole);
// Filter out the billing role if not in Formbricks Cloud
const filteredRoles = roles.filter((role) => !(role === "billing" && !IS_FORMBRICKS_CLOUD));
return ok({
data: filteredRoles,
});
} catch {
return err({
type: "internal_server_error",
details: [{ field: "roles", issue: "Failed to get roles" }],
});
}
};
@@ -1,14 +1,14 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getRoles } from "@/modules/api/v2/management/roles/lib/roles";
import { getRoles } from "@/modules/api/v2/roles/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
handler: async () => {
const res = await getRoles();
const res = getRoles();
if (res.ok) {
return responses.successResponse(res.data);
+24 -3
View File
@@ -1,9 +1,10 @@
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSSOCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import type { Account, NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import {
EMAIL_VERIFICATION_DISABLED,
@@ -61,6 +62,9 @@ export const authOptions: NextAuthOptions = {
if (!user.password) {
throw new Error("User has no password stored");
}
if (user.isActive === false) {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
const isValid = await verifyPassword(credentials.password, user.password);
@@ -162,6 +166,10 @@ export const authOptions: NextAuthOptions = {
throw new Error("Email already verified");
}
if (user.isActive === false) {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
user = await updateUser(user.id, { emailVerified: new Date() });
// send new user to brevo after email verification
@@ -187,6 +195,7 @@ export const authOptions: NextAuthOptions = {
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
@@ -194,20 +203,32 @@ export const authOptions: NextAuthOptions = {
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }: { user: TUser; account: Account }) {
const cookieStore = await cookies();
const callbackUrl = cookieStore.get("next-auth.callback-url")?.value || "";
if (account?.provider === "credentials" || account?.provider === "token") {
// check if user's email is verified or not
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
throw new Error("Email Verification is Pending");
}
await updateUserLastLoginAt(user.email);
return true;
}
if (ENTERPRISE_LICENSE_KEY) {
return handleSSOCallback({ user, account });
const result = await handleSsoCallback({ user, account, callbackUrl });
if (result) {
await updateUserLastLoginAt(user.email);
}
return result;
}
await updateUserLastLoginAt(user.email);
return true;
},
},
+2
View File
@@ -18,4 +18,6 @@ export const mockUser: TUser = {
},
role: "other",
locale: "en-US",
lastLoginAt: new Date("2024-01-01T00:00:00.000Z"),
isActive: true,
};
+24 -1
View File
@@ -5,7 +5,7 @@ import { PrismaErrorType } from "@formbricks/database/types/error";
import { userCache } from "@formbricks/lib/user/cache";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
import { createUser, getUser, getUserByEmail, updateUser } from "./user";
import { createUser, getUser, getUserByEmail, updateUser, updateUserLastLoginAt } from "./user";
const mockPrismaUser = {
...mockUser,
@@ -96,6 +96,29 @@ describe("User Management", () => {
});
});
describe("updateUserLastLoginAt", () => {
const mockUpdateData = { name: "Updated Name" };
it("updates a user successfully", async () => {
vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name });
const result = await updateUserLastLoginAt(mockUser.email);
expect(result).toEqual(void 0);
expect(userCache.revalidate).toHaveBeenCalled();
});
it("throws ResourceNotFoundError when user doesn't exist", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError);
});
});
describe("getUserByEmail", () => {
const mockEmail = "test@example.com";
+29
View File
@@ -43,6 +43,34 @@ export const updateUser = async (id: string, data: TUserUpdateInput) => {
}
};
export const updateUserLastLoginAt = async (email: string) => {
validateInputs([email, ZUserEmail]);
try {
const updatedUser = await prisma.user.update({
where: {
email,
},
data: {
lastLoginAt: new Date(),
},
});
userCache.revalidate({
email: updatedUser.email,
id: updatedUser.id,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("email", email);
}
throw error;
}
};
export const getUserByEmail = reactCache(async (email: string) =>
cache(
async () => {
@@ -58,6 +86,7 @@ export const getUserByEmail = reactCache(async (email: string) =>
locale: true,
email: true,
emailVerified: true,
isActive: true,
},
});
@@ -256,6 +256,7 @@ export const LoginForm = ({
samlTenant={samlTenant}
samlProduct={samlProduct}
callbackUrl={callbackUrl}
source="signin"
/>
)}
</div>
@@ -280,6 +280,7 @@ export const SignupForm = ({
samlTenant={samlTenant}
samlProduct={samlProduct}
callbackUrl={callbackUrl}
source="signup"
/>
)}
<TermsPrivacyLinks termsUrl={termsUrl} privacyUrl={privacyUrl} />
@@ -0,0 +1,246 @@
import { inviteCache } from "@/lib/cache/invite";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteInvite, getInvite, getIsValidInviteToken } from "./invite";
// Mock data
const mockInviteId = "test-invite-id";
const mockOrganizationId = "test-org-id";
const mockCreatorId = "test-creator-id";
const mockInvite = {
id: mockInviteId,
email: "test@test.com",
name: "Test Name",
organizationId: mockOrganizationId,
creatorId: mockCreatorId,
acceptorId: null,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now
deprecatedRole: null,
role: "member" as const,
teamIds: ["team-1"],
creator: {
name: "Test Creator",
email: "creator@test.com",
locale: "en",
},
};
// Mock prisma methods
vi.mock("@formbricks/database", () => ({
prisma: {
invite: {
delete: vi.fn(),
findUnique: vi.fn(),
},
},
}));
// Mock cache
vi.mock("@/lib/cache/invite", () => ({
inviteCache: {
revalidate: vi.fn(),
tag: {
byId: (id: string) => `invite-${id}`,
},
},
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Invite Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("deleteInvite", () => {
it("deletes an invite successfully and invalidates cache", async () => {
vi.mocked(prisma.invite.delete).mockResolvedValue(mockInvite);
const result = await deleteInvite(mockInviteId);
expect(result).toBe(true);
expect(prisma.invite.delete).toHaveBeenCalledWith({
where: { id: mockInviteId },
select: { id: true, organizationId: true },
});
expect(inviteCache.revalidate).toHaveBeenCalledWith({
id: mockInviteId,
organizationId: mockOrganizationId,
});
});
it("throws DatabaseError when invite doesn't exist", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.invite.delete).mockRejectedValue(errToThrow);
await expect(deleteInvite(mockInviteId)).rejects.toThrow(DatabaseError);
});
it("throws DatabaseError for other Prisma errors", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.invite.delete).mockRejectedValue(errToThrow);
await expect(deleteInvite(mockInviteId)).rejects.toThrow(DatabaseError);
});
it("throws DatabaseError for generic errors", async () => {
vi.mocked(prisma.invite.delete).mockRejectedValue(new Error("Generic error"));
await expect(deleteInvite(mockInviteId)).rejects.toThrow(DatabaseError);
});
});
describe("getInvite", () => {
it("retrieves an invite with creator details successfully", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite);
const result = await getInvite(mockInviteId);
expect(result).toEqual(mockInvite);
expect(prisma.invite.findUnique).toHaveBeenCalledWith({
where: { id: mockInviteId },
select: {
id: true,
organizationId: true,
role: true,
teamIds: true,
creator: {
select: {
name: true,
email: true,
locale: true,
},
},
},
});
});
it("returns null when invite doesn't exist", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
const result = await getInvite(mockInviteId);
expect(result).toBeNull();
});
it("throws DatabaseError on prisma error", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.invite.findUnique).mockRejectedValue(errToThrow);
await expect(getInvite(mockInviteId)).rejects.toThrow(DatabaseError);
});
it("throws DatabaseError for generic errors", async () => {
vi.mocked(prisma.invite.findUnique).mockRejectedValue(new Error("Generic error"));
await expect(getInvite(mockInviteId)).rejects.toThrow(DatabaseError);
});
});
describe("getIsValidInviteToken", () => {
it("returns true for valid invite", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite);
const result = await getIsValidInviteToken(mockInviteId);
expect(result).toBe(true);
expect(prisma.invite.findUnique).toHaveBeenCalledWith({
where: { id: mockInviteId },
});
});
it("returns false when invite doesn't exist", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
const result = await getIsValidInviteToken(mockInviteId);
expect(result).toBe(false);
});
it("returns false for expired invite", async () => {
const expiredInvite = {
...mockInvite,
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
};
vi.mocked(prisma.invite.findUnique).mockResolvedValue(expiredInvite);
const result = await getIsValidInviteToken(mockInviteId);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
{
inviteId: mockInviteId,
expiresAt: expiredInvite.expiresAt,
},
"SSO: Invite token expired"
);
});
it("returns false and logs error when database error occurs", async () => {
const error = new Error("Database error");
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
const result = await getIsValidInviteToken(mockInviteId);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(error, "Error getting invite");
});
it("returns false for invite with null expiresAt", async () => {
const invalidInvite = {
...mockInvite,
expiresAt: null,
};
vi.mocked(prisma.invite.findUnique).mockResolvedValue(invalidInvite);
const result = await getIsValidInviteToken(mockInviteId);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
{
inviteId: mockInviteId,
expiresAt: null,
},
"SSO: Invite token expired"
);
});
it("returns false for invite with invalid expiresAt", async () => {
const invalidInvite = {
...mockInvite,
expiresAt: new Date("invalid-date"),
};
vi.mocked(prisma.invite.findUnique).mockResolvedValue(invalidInvite);
const result = await getIsValidInviteToken(mockInviteId);
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
{
inviteId: mockInviteId,
expiresAt: invalidInvite.expiresAt,
},
"SSO: Invite token expired"
);
});
});
});
+47 -4
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
@@ -32,8 +33,7 @@ export const deleteInvite = async (inviteId: string): Promise<boolean> => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
throw new DatabaseError(error instanceof Error ? error.message : "Unknown error occurred");
}
};
@@ -66,8 +66,7 @@ export const getInvite = reactCache(
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
throw new DatabaseError(error instanceof Error ? error.message : "Unknown error occurred");
}
},
[`signup-getInvite-${inviteId}`],
@@ -76,3 +75,47 @@ export const getInvite = reactCache(
}
)()
);
export const getIsValidInviteToken = reactCache(
async (inviteId: string): Promise<boolean> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: { id: inviteId },
});
if (!invite) {
return false;
}
if (!invite.expiresAt || isNaN(invite.expiresAt.getTime())) {
logger.error(
{
inviteId,
expiresAt: invite.expiresAt,
},
"SSO: Invite token expired"
);
return false;
}
if (invite.expiresAt < new Date()) {
logger.error(
{
inviteId,
expiresAt: invite.expiresAt,
},
"SSO: Invite token expired"
);
return false;
}
return true;
} catch (err) {
logger.error(err, "Error getting invite");
return false;
}
},
[`getIsValidInviteToken-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);
+154
View File
@@ -0,0 +1,154 @@
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { SignupPage } from "./page";
// Mock the necessary dependencies
vi.mock("@/modules/auth/components/testimonial", () => ({
Testimonial: () => <div data-testid="testimonial">Testimonial</div>,
}));
vi.mock("@/modules/auth/components/form-wrapper", () => ({
FormWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="form-wrapper">{children}</div>
),
}));
vi.mock("@/modules/auth/signup/components/signup-form", () => ({
SignupForm: () => <div data-testid="signup-form">SignupForm</div>,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(),
}));
vi.mock("@/modules/auth/signup/lib/invite", () => ({
getIsValidInviteToken: vi.fn(),
}));
vi.mock("@formbricks/lib/jwt", () => ({
verifyInviteToken: vi.fn(),
}));
vi.mock("@formbricks/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
}));
// Mock environment variables and constants
vi.mock("@formbricks/lib/constants", () => ({
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: true,
GITHUB_OAUTH_ENABLED: true,
AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true,
OIDC_DISPLAY_NAME: "OpenID",
SAML_OAUTH_ENABLED: true,
SAML_TENANT: "test-tenant",
SAML_PRODUCT: "test-product",
IS_TURNSTILE_CONFIGURED: true,
WEBAPP_URL: "http://localhost:3000",
TERMS_URL: "http://localhost:3000/terms",
PRIVACY_URL: "http://localhost:3000/privacy",
DEFAULT_ORGANIZATION_ID: "test-org-id",
DEFAULT_ORGANIZATION_ROLE: "admin",
}));
describe("SignupPage", () => {
const mockSearchParams = {
inviteToken: "test-token",
email: "test@example.com",
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("renders the signup page with all components when signup is enabled", async () => {
// Mock the license check functions to return true
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en");
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
const result = await SignupPage({ searchParams: mockSearchParams });
render(result);
// Verify that all components are rendered
expect(screen.getByTestId("testimonial")).toBeInTheDocument();
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("signup-form")).toBeInTheDocument();
});
it("calls notFound when signup is disabled and no valid invite token is provided", async () => {
// Mock the license check functions to return false
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
vi.mocked(verifyInviteToken).mockImplementation(() => {
throw new Error("Invalid token");
});
await SignupPage({ searchParams: {} });
expect(notFound).toHaveBeenCalled();
});
it("calls notFound when invite token is invalid", async () => {
// Mock the license check functions to return false
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
vi.mocked(verifyInviteToken).mockImplementation(() => {
throw new Error("Invalid token");
});
await SignupPage({ searchParams: { inviteToken: "invalid-token" } });
expect(notFound).toHaveBeenCalled();
});
it("calls notFound when invite token is valid but invite is not found", async () => {
// Mock the license check functions to return false
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
vi.mocked(getIsValidInviteToken).mockResolvedValue(false);
await SignupPage({ searchParams: { inviteToken: "test-token" } });
expect(notFound).toHaveBeenCalled();
});
it("renders the page with email from search params", async () => {
// Mock the license check functions to return true
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en");
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "test-invite-id" });
vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
const result = await SignupPage({ searchParams: { email: "test@example.com" } });
render(result);
// Verify that the form is rendered with the email from search params
expect(screen.getByTestId("signup-form")).toBeInTheDocument();
});
});
+14 -3
View File
@@ -1,5 +1,6 @@
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { Testimonial } from "@/modules/auth/components/testimonial";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
@@ -25,6 +26,7 @@ import {
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { SignupForm } from "./components/signup-form";
@@ -38,11 +40,20 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
]);
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
const locale = await findMatchingLocale();
if (!inviteToken && (!SIGNUP_ENABLED || !isMultOrgEnabled)) {
notFound();
if (!SIGNUP_ENABLED || !isMultOrgEnabled) {
if (!inviteToken) notFound();
try {
const { inviteId } = verifyInviteToken(inviteToken);
const isValidInviteToken = await getIsValidInviteToken(inviteId);
if (!isValidInviteToken) notFound();
} catch {
notFound();
}
}
const emailFromSearchParams = searchParams["email"];
return (
@@ -1,6 +1,6 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { upsertBulkContacts } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact";
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -28,6 +28,8 @@ export const mockUser: TUser = {
locale: "en-US",
imageUrl: null,
role: null,
lastLoginAt: new Date(),
isActive: true,
};
// Mock session
@@ -0,0 +1,84 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
import { AzureButton } from "./azure-button";
// Mock next-auth/react
vi.mock("next-auth/react", () => ({
signIn: vi.fn(),
}));
// Mock localStorage
const mockLocalStorage = {
setItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
describe("AzureButton", () => {
const defaultProps = {
source: "signin" as const,
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders correctly with default props", () => {
render(<AzureButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_azure" });
expect(button).toBeInTheDocument();
});
it("renders with last used indicator when lastUsed is true", () => {
render(<AzureButton {...defaultProps} lastUsed={true} />);
expect(screen.getByText("auth.last_used")).toBeInTheDocument();
});
it("sets localStorage item and calls signIn on click", async () => {
render(<AzureButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_azure" });
fireEvent.click(button);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(FORMBRICKS_LOGGED_IN_WITH_LS, "Azure");
expect(signIn).toHaveBeenCalledWith("azure-ad", {
redirect: true,
callbackUrl: "/?source=signin",
});
});
it("uses inviteUrl in callbackUrl when provided", async () => {
const inviteUrl = "https://example.com/invite";
render(<AzureButton {...defaultProps} inviteUrl={inviteUrl} />);
const button = screen.getByRole("button", { name: "auth.continue_with_azure" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("azure-ad", {
redirect: true,
callbackUrl: "https://example.com/invite?source=signin",
});
});
it("handles signup source correctly", async () => {
render(<AzureButton {...defaultProps} source="signup" />);
const button = screen.getByRole("button", { name: "auth.continue_with_azure" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("azure-ad", {
redirect: true,
callbackUrl: "/?source=signup",
});
});
it("triggers direct redirect when directRedirect is true", () => {
render(<AzureButton {...defaultProps} directRedirect={true} />);
expect(signIn).toHaveBeenCalledWith("azure-ad", {
redirect: true,
callbackUrl: "/?source=signin",
});
});
});
@@ -1,5 +1,6 @@
"use client";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { MicrosoftIcon } from "@/modules/ui/components/icons";
import { useTranslate } from "@tolgee/react";
@@ -11,20 +12,22 @@ interface AzureButtonProps {
inviteUrl?: string;
directRedirect?: boolean;
lastUsed?: boolean;
source: "signin" | "signup";
}
export const AzureButton = ({ inviteUrl, directRedirect = false, lastUsed }: AzureButtonProps) => {
export const AzureButton = ({ inviteUrl, directRedirect = false, lastUsed, source }: AzureButtonProps) => {
const { t } = useTranslate();
const handleLogin = useCallback(async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Azure");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
await signIn("azure-ad", {
redirect: true,
callbackUrl: inviteUrl ? inviteUrl : "/",
callbackUrl: callbackUrlWithSource,
});
}, [inviteUrl]);
}, [inviteUrl, source]);
useEffect(() => {
if (directRedirect) {
@@ -0,0 +1,76 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
import { GithubButton } from "./github-button";
// Mock next-auth/react
vi.mock("next-auth/react", () => ({
signIn: vi.fn(),
}));
// Mock localStorage
const mockLocalStorage = {
setItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
describe("GithubButton", () => {
const defaultProps = {
source: "signin" as const,
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders correctly with default props", () => {
render(<GithubButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_github" });
expect(button).toBeInTheDocument();
});
it("renders with last used indicator when lastUsed is true", () => {
render(<GithubButton {...defaultProps} lastUsed={true} />);
expect(screen.getByText("auth.last_used")).toBeInTheDocument();
});
it("sets localStorage item and calls signIn on click", async () => {
render(<GithubButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_github" });
fireEvent.click(button);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(FORMBRICKS_LOGGED_IN_WITH_LS, "Github");
expect(signIn).toHaveBeenCalledWith("github", {
redirect: true,
callbackUrl: "/?source=signin",
});
});
it("uses inviteUrl in callbackUrl when provided", async () => {
const inviteUrl = "https://example.com/invite";
render(<GithubButton {...defaultProps} inviteUrl={inviteUrl} />);
const button = screen.getByRole("button", { name: "auth.continue_with_github" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("github", {
redirect: true,
callbackUrl: "https://example.com/invite?source=signin",
});
});
it("handles signup source correctly", async () => {
render(<GithubButton {...defaultProps} source="signup" />);
const button = screen.getByRole("button", { name: "auth.continue_with_github" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("github", {
redirect: true,
callbackUrl: "/?source=signup",
});
});
});
@@ -1,5 +1,6 @@
"use client";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { GithubIcon } from "@/modules/ui/components/icons";
import { useTranslate } from "@tolgee/react";
@@ -9,17 +10,20 @@ import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface GithubButtonProps {
inviteUrl?: string;
lastUsed?: boolean;
source: "signin" | "signup";
}
export const GithubButton = ({ inviteUrl, lastUsed }: GithubButtonProps) => {
export const GithubButton = ({ inviteUrl, lastUsed, source }: GithubButtonProps) => {
const { t } = useTranslate();
const handleLogin = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Github");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
await signIn("github", {
redirect: true,
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
callbackUrl: callbackUrlWithSource,
});
};
@@ -0,0 +1,76 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
import { GoogleButton } from "./google-button";
// Mock next-auth/react
vi.mock("next-auth/react", () => ({
signIn: vi.fn(),
}));
// Mock localStorage
const mockLocalStorage = {
setItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
describe("GoogleButton", () => {
const defaultProps = {
source: "signin" as const,
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders correctly with default props", () => {
render(<GoogleButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_google" });
expect(button).toBeInTheDocument();
});
it("renders with last used indicator when lastUsed is true", () => {
render(<GoogleButton {...defaultProps} lastUsed={true} />);
expect(screen.getByText("auth.last_used")).toBeInTheDocument();
});
it("sets localStorage item and calls signIn on click", async () => {
render(<GoogleButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_google" });
fireEvent.click(button);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(FORMBRICKS_LOGGED_IN_WITH_LS, "Google");
expect(signIn).toHaveBeenCalledWith("google", {
redirect: true,
callbackUrl: "/?source=signin",
});
});
it("uses inviteUrl in callbackUrl when provided", async () => {
const inviteUrl = "https://example.com/invite";
render(<GoogleButton {...defaultProps} inviteUrl={inviteUrl} />);
const button = screen.getByRole("button", { name: "auth.continue_with_google" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("google", {
redirect: true,
callbackUrl: "https://example.com/invite?source=signin",
});
});
it("handles signup source correctly", async () => {
render(<GoogleButton {...defaultProps} source="signup" />);
const button = screen.getByRole("button", { name: "auth.continue_with_google" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("google", {
redirect: true,
callbackUrl: "/?source=signup",
});
});
});
@@ -1,5 +1,6 @@
"use client";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { GoogleIcon } from "@/modules/ui/components/icons";
import { useTranslate } from "@tolgee/react";
@@ -9,17 +10,20 @@ import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface GoogleButtonProps {
inviteUrl?: string;
lastUsed?: boolean;
source: "signin" | "signup";
}
export const GoogleButton = ({ inviteUrl, lastUsed }: GoogleButtonProps) => {
export const GoogleButton = ({ inviteUrl, lastUsed, source }: GoogleButtonProps) => {
const { t } = useTranslate();
const handleLogin = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Google");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
await signIn("google", {
redirect: true,
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
callbackUrl: callbackUrlWithSource,
});
};
@@ -0,0 +1,91 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
import { OpenIdButton } from "./open-id-button";
// Mock next-auth/react
vi.mock("next-auth/react", () => ({
signIn: vi.fn(),
}));
// Mock localStorage
const mockLocalStorage = {
setItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
describe("OpenIdButton", () => {
const defaultProps = {
source: "signin" as const,
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders correctly with default props", () => {
render(<OpenIdButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_openid" });
expect(button).toBeInTheDocument();
});
it("renders with custom text when provided", () => {
const customText = "Custom OpenID Text";
render(<OpenIdButton {...defaultProps} text={customText} />);
const button = screen.getByRole("button", { name: customText });
expect(button).toBeInTheDocument();
});
it("renders with last used indicator when lastUsed is true", () => {
render(<OpenIdButton {...defaultProps} lastUsed={true} />);
expect(screen.getByText("auth.last_used")).toBeInTheDocument();
});
it("sets localStorage item and calls signIn on click", async () => {
render(<OpenIdButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_openid" });
fireEvent.click(button);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(FORMBRICKS_LOGGED_IN_WITH_LS, "OpenID");
expect(signIn).toHaveBeenCalledWith("openid", {
redirect: true,
callbackUrl: "/?source=signin",
});
});
it("uses inviteUrl in callbackUrl when provided", async () => {
const inviteUrl = "https://example.com/invite";
render(<OpenIdButton {...defaultProps} inviteUrl={inviteUrl} />);
const button = screen.getByRole("button", { name: "auth.continue_with_openid" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("openid", {
redirect: true,
callbackUrl: "https://example.com/invite?source=signin",
});
});
it("handles signup source correctly", async () => {
render(<OpenIdButton {...defaultProps} source="signup" />);
const button = screen.getByRole("button", { name: "auth.continue_with_openid" });
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("openid", {
redirect: true,
callbackUrl: "/?source=signup",
});
});
it("triggers direct redirect when directRedirect is true", () => {
render(<OpenIdButton {...defaultProps} directRedirect={true} />);
expect(signIn).toHaveBeenCalledWith("openid", {
redirect: true,
callbackUrl: "/?source=signin",
});
});
});
@@ -1,5 +1,6 @@
"use client";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
@@ -11,19 +12,28 @@ interface OpenIdButtonProps {
lastUsed?: boolean;
directRedirect?: boolean;
text?: string;
source: "signin" | "signup";
}
export const OpenIdButton = ({ inviteUrl, lastUsed, directRedirect = false, text }: OpenIdButtonProps) => {
export const OpenIdButton = ({
inviteUrl,
lastUsed,
directRedirect = false,
text,
source,
}: OpenIdButtonProps) => {
const { t } = useTranslate();
const handleLogin = useCallback(async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "OpenID");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
await signIn("openid", {
redirect: true,
callbackUrl: inviteUrl ? inviteUrl : "/",
callbackUrl: callbackUrlWithSource,
});
}, [inviteUrl]);
}, [inviteUrl, source]);
useEffect(() => {
if (directRedirect) {
@@ -0,0 +1,130 @@
import { doesSamlConnectionExistAction } from "@/modules/ee/sso/actions";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import toast from "react-hot-toast";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
import { SamlButton } from "./saml-button";
// Mock next-auth/react
vi.mock("next-auth/react", () => ({
signIn: vi.fn().mockResolvedValue(undefined),
}));
// Mock localStorage
const mockLocalStorage = {
setItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
// Mock actions
vi.mock("@/modules/ee/sso/actions", () => ({
doesSamlConnectionExistAction: vi.fn(),
}));
// Mock toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
describe("SamlButton", () => {
const defaultProps = {
source: "signin" as const,
samlTenant: "test-tenant",
samlProduct: "test-product",
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders correctly with default props", () => {
render(<SamlButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_saml" });
expect(button).toBeInTheDocument();
});
it("renders with last used indicator when lastUsed is true", () => {
render(<SamlButton {...defaultProps} lastUsed={true} />);
expect(screen.getByText("auth.last_used")).toBeInTheDocument();
});
it("sets localStorage item and calls signIn on click when SAML connection exists", async () => {
vi.mocked(doesSamlConnectionExistAction).mockResolvedValue({ data: true });
render(<SamlButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_saml" });
await fireEvent.click(button);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(FORMBRICKS_LOGGED_IN_WITH_LS, "Saml");
expect(signIn).toHaveBeenCalledWith(
"saml",
{
redirect: true,
callbackUrl: "/?source=signin",
},
{
tenant: "test-tenant",
product: "test-product",
}
);
});
it("shows error toast when SAML connection does not exist", async () => {
vi.mocked(doesSamlConnectionExistAction).mockResolvedValue({ data: false });
render(<SamlButton {...defaultProps} />);
const button = screen.getByRole("button", { name: "auth.continue_with_saml" });
await fireEvent.click(button);
expect(toast.error).toHaveBeenCalledWith("auth.saml_connection_error");
expect(signIn).not.toHaveBeenCalled();
});
it("uses inviteUrl in callbackUrl when provided", async () => {
vi.mocked(doesSamlConnectionExistAction).mockResolvedValue({ data: true });
const inviteUrl = "https://example.com/invite";
render(<SamlButton {...defaultProps} inviteUrl={inviteUrl} />);
const button = screen.getByRole("button", { name: "auth.continue_with_saml" });
await fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith(
"saml",
{
redirect: true,
callbackUrl: "https://example.com/invite?source=signin",
},
{
tenant: "test-tenant",
product: "test-product",
}
);
});
it("handles signup source correctly", async () => {
vi.mocked(doesSamlConnectionExistAction).mockResolvedValue({ data: true });
render(<SamlButton {...defaultProps} source="signup" />);
const button = screen.getByRole("button", { name: "auth.continue_with_saml" });
await fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith(
"saml",
{
redirect: true,
callbackUrl: "/?source=signup",
},
{
tenant: "test-tenant",
product: "test-product",
}
);
});
});
@@ -1,6 +1,7 @@
"use client";
import { doesSamlConnectionExistAction } from "@/modules/ee/sso/actions";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { LockIcon } from "lucide-react";
@@ -14,9 +15,10 @@ interface SamlButtonProps {
lastUsed?: boolean;
samlTenant: string;
samlProduct: string;
source: "signin" | "signup";
}
export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct }: SamlButtonProps) => {
export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct, source }: SamlButtonProps) => {
const { t } = useTranslate();
const [isLoading, setIsLoading] = useState(false);
@@ -32,11 +34,13 @@ export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct }: Sam
return;
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
signIn(
"saml",
{
redirect: true,
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
callbackUrl: callbackUrlWithSource,
},
{
tenant: samlTenant,
@@ -0,0 +1,137 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SSOOptions } from "./sso-options";
// Mock environment variables
vi.mock("@formbricks/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
},
}));
// Mock the translation hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock the individual SSO buttons
vi.mock("./google-button", () => ({
GoogleButton: ({ lastUsed, source }: any) => (
<div data-testid="google-button" data-last-used={lastUsed} data-source={source}>
Google Button
</div>
),
}));
vi.mock("./github-button", () => ({
GithubButton: ({ lastUsed, source }: any) => (
<div data-testid="github-button" data-last-used={lastUsed} data-source={source}>
Github Button
</div>
),
}));
vi.mock("./azure-button", () => ({
AzureButton: ({ lastUsed, source }: any) => (
<div data-testid="azure-button" data-last-used={lastUsed} data-source={source}>
Azure Button
</div>
),
}));
vi.mock("./open-id-button", () => ({
OpenIdButton: ({ lastUsed, source, text }: any) => (
<div data-testid="openid-button" data-last-used={lastUsed} data-source={source}>
{text}
</div>
),
}));
vi.mock("./saml-button", () => ({
SamlButton: ({ lastUsed, source, samlTenant, samlProduct }: any) => (
<div
data-testid="saml-button"
data-last-used={lastUsed}
data-source={source}
data-tenant={samlTenant}
data-product={samlProduct}>
Saml Button
</div>
),
}));
describe("SSOOptions Component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const defaultProps = {
googleOAuthEnabled: true,
githubOAuthEnabled: true,
azureOAuthEnabled: true,
oidcOAuthEnabled: true,
oidcDisplayName: "OpenID",
callbackUrl: "http://localhost:3000",
samlSsoEnabled: true,
samlTenant: "test-tenant",
samlProduct: "test-product",
source: "signin" as const,
};
it("renders all SSO options when all are enabled", () => {
render(<SSOOptions {...defaultProps} />);
expect(screen.getByTestId("google-button")).toBeInTheDocument();
expect(screen.getByTestId("github-button")).toBeInTheDocument();
expect(screen.getByTestId("azure-button")).toBeInTheDocument();
expect(screen.getByTestId("openid-button")).toBeInTheDocument();
expect(screen.getByTestId("saml-button")).toBeInTheDocument();
});
it("only renders enabled SSO options", () => {
render(
<SSOOptions
{...defaultProps}
googleOAuthEnabled={false}
githubOAuthEnabled={false}
azureOAuthEnabled={false}
/>
);
expect(screen.queryByTestId("google-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("github-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("azure-button")).not.toBeInTheDocument();
expect(screen.getByTestId("openid-button")).toBeInTheDocument();
expect(screen.getByTestId("saml-button")).toBeInTheDocument();
});
it("passes correct props to OpenID button", () => {
render(<SSOOptions {...defaultProps} />);
const openIdButton = screen.getByTestId("openid-button");
expect(openIdButton).toHaveAttribute("data-source", "signin");
expect(openIdButton).toHaveTextContent("auth.continue_with_oidc");
});
it("passes correct props to SAML button", () => {
render(<SSOOptions {...defaultProps} />);
const samlButton = screen.getByTestId("saml-button");
expect(samlButton).toHaveAttribute("data-source", "signin");
expect(samlButton).toHaveAttribute("data-tenant", "test-tenant");
expect(samlButton).toHaveAttribute("data-product", "test-product");
});
it("passes correct source prop to all buttons", () => {
render(<SSOOptions {...defaultProps} source="signup" />);
expect(screen.getByTestId("google-button")).toHaveAttribute("data-source", "signup");
expect(screen.getByTestId("github-button")).toHaveAttribute("data-source", "signup");
expect(screen.getByTestId("azure-button")).toHaveAttribute("data-source", "signup");
expect(screen.getByTestId("openid-button")).toHaveAttribute("data-source", "signup");
expect(screen.getByTestId("saml-button")).toHaveAttribute("data-source", "signup");
});
});
@@ -19,6 +19,7 @@ interface SSOOptionsProps {
samlSsoEnabled: boolean;
samlTenant: string;
samlProduct: string;
source: "signin" | "signup";
}
export const SSOOptions = ({
@@ -31,6 +32,7 @@ export const SSOOptions = ({
samlSsoEnabled,
samlTenant,
samlProduct,
source,
}: SSOOptionsProps) => {
const { t } = useTranslate();
const [lastLoggedInWith, setLastLoggedInWith] = useState("");
@@ -44,17 +46,20 @@ export const SSOOptions = ({
return (
<div className="space-y-2">
{googleOAuthEnabled && (
<GoogleButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Google"} />
<GoogleButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Google"} source={source} />
)}
{githubOAuthEnabled && (
<GithubButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Github"} />
<GithubButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Github"} source={source} />
)}
{azureOAuthEnabled && (
<AzureButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Azure"} source={source} />
)}
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Azure"} />}
{oidcOAuthEnabled && (
<OpenIdButton
inviteUrl={callbackUrl}
lastUsed={lastLoggedInWith === "OpenID"}
text={t("auth.continue_with_oidc", { oidcDisplayName })}
source={source}
/>
)}
{samlSsoEnabled && (
@@ -63,6 +68,7 @@ export const SSOOptions = ({
lastUsed={lastLoggedInWith === "Saml"}
samlTenant={samlTenant}
samlProduct={samlProduct}
source={source}
/>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More