mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-24 11:39:22 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c7f92a4d7 | |||
| c653841037 | |||
| ec314c14ea | |||
| c03e60ac0b | |||
| cbf2343143 | |||
| 9d9b3ac543 | |||
| 591b35a70b | |||
| f0c7b881d3 | |||
| 3fd5515db1 |
@@ -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 }}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/roles/route";
|
||||
|
||||
export { GET };
|
||||
+5
@@ -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);
|
||||
};
|
||||
+2
-2
@@ -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";
|
||||
+26
-4
@@ -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",
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
-1
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
+132
@@ -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 }] });
|
||||
}
|
||||
};
|
||||
+134
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
+37
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
+166
@@ -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",
|
||||
},
|
||||
];
|
||||
+4
-3
@@ -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" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
+3
-3
@@ -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);
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,4 +18,6 @@ export const mockUser: TUser = {
|
||||
},
|
||||
role: "other",
|
||||
locale: "en-US",
|
||||
lastLoginAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user