mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-12 17:10:06 -06:00
feat: Improve auth & permission system (#2845)
This commit is contained in:
@@ -7,6 +7,7 @@ import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
|
||||
import {
|
||||
TProductConfigChannel,
|
||||
@@ -45,18 +46,27 @@ export const ProductSettings = ({
|
||||
|
||||
const addProduct = async (data: TProductUpdateInput) => {
|
||||
try {
|
||||
const product = await createProductAction(organizationId, {
|
||||
...data,
|
||||
config: { channel, industry },
|
||||
const createProductResponse = await createProductAction({
|
||||
organizationId,
|
||||
data: {
|
||||
...data,
|
||||
config: { channel, industry },
|
||||
},
|
||||
});
|
||||
// get production environment
|
||||
const productionEnvironment = product.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (channel !== "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
|
||||
if (createProductResponse?.data) {
|
||||
// get production environment
|
||||
const productionEnvironment = createProductResponse.data.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (channel !== "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
} else {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
}
|
||||
} else {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
const errorMessage = getFormattedErrorMessage(createProductResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Product creation failed");
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { canUserAccessPerson } from "@formbricks/lib/person/auth";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromPersonId } from "@formbricks/lib/organization/utils";
|
||||
import { deletePerson } from "@formbricks/lib/person/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
|
||||
export const deletePersonAction = async (personId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
const ZPersonDeleteAction = z.object({
|
||||
personId: z.string(),
|
||||
});
|
||||
|
||||
const isAuthorized = await canUserAccessPerson(session.user.id, personId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
export const deletePersonAction = authenticatedActionClient
|
||||
.schema(ZPersonDeleteAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromPersonId(parsedInput.personId),
|
||||
rules: ["person", "delete"],
|
||||
});
|
||||
|
||||
await deletePerson(personId);
|
||||
};
|
||||
return await deletePerson(parsedInput.personId);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
interface DeletePersonButtonProps {
|
||||
@@ -22,14 +23,21 @@ export const DeletePersonButton = ({ environmentId, personId, isViewer }: Delete
|
||||
const handleDeletePerson = async () => {
|
||||
try {
|
||||
setIsDeletingPerson(true);
|
||||
await deletePersonAction(personId);
|
||||
router.refresh();
|
||||
router.push(`/environments/${environmentId}/people`);
|
||||
toast.success("Person deleted successfully.");
|
||||
const deletePersonResponse = await deletePersonAction({ personId });
|
||||
|
||||
if (deletePersonResponse?.data) {
|
||||
router.refresh();
|
||||
router.push(`/environments/${environmentId}/people`);
|
||||
toast.success("Person deleted successfully.");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deletePersonResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsDeletingPerson(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
import { Organization } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { createMembership, getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createOrganization } from "@formbricks/lib/organization/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { ZProductUpdateInput } from "@formbricks/types/product";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
export const createShortUrlAction = async (url: string) => {
|
||||
@@ -71,33 +74,39 @@ export const createOrganizationAction = async (organizationName: string): Promis
|
||||
return newOrganization;
|
||||
};
|
||||
|
||||
export const createProductAction = async (
|
||||
organizationId: string,
|
||||
productInput: TProductUpdateInput
|
||||
): Promise<TProduct> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authenticated");
|
||||
const ZCreateProductAction = z.object({
|
||||
organizationId: z.string(),
|
||||
data: ZProductUpdateInput,
|
||||
});
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId);
|
||||
if (!membership || membership.role === "viewer") {
|
||||
throw new AuthorizationError("Product creation not allowed");
|
||||
}
|
||||
export const createProductAction = authenticatedActionClient
|
||||
.schema(ZCreateProductAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const product = await createProduct(organizationId, productInput);
|
||||
const updatedNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
await checkAuthorization({
|
||||
schema: ZProductUpdateInput,
|
||||
data: parsedInput.data,
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
rules: ["product", "create"],
|
||||
});
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
const product = await createProduct(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return product;
|
||||
});
|
||||
|
||||
return product;
|
||||
};
|
||||
|
||||
@@ -1,104 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromProductId } from "@formbricks/lib/organization/utils";
|
||||
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/product/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { ZProductUpdateInput } from "@formbricks/types/product";
|
||||
|
||||
export const updateProductAction = async (
|
||||
environmentId: string,
|
||||
productId: string,
|
||||
data: Partial<TProductUpdateInput>
|
||||
): Promise<TProduct> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const ZUpdateProductAction = z.object({
|
||||
productId: z.string(),
|
||||
data: ZProductUpdateInput,
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
export const updateProductAction = authenticatedActionClient
|
||||
.schema(ZUpdateProductAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
schema: ZProductUpdateInput,
|
||||
data: parsedInput.data,
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromProductId(parsedInput.productId),
|
||||
rules: ["product", "update"],
|
||||
});
|
||||
|
||||
// get the environment from service and check if the user is allowed to update the product
|
||||
let environment: TEnvironment | null = null;
|
||||
return await updateProduct(parsedInput.productId, parsedInput.data);
|
||||
});
|
||||
|
||||
try {
|
||||
environment = await getEnvironment(environmentId);
|
||||
const ZProductDeleteAction = z.object({
|
||||
productId: z.string(),
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("Environment", "Environment not found");
|
||||
export const deleteProductAction = authenticatedActionClient
|
||||
.schema(ZProductDeleteAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
// get organizationId from productId
|
||||
const organizationId = await getOrganizationIdFromProductId(parsedInput.productId);
|
||||
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
rules: ["product", "delete"],
|
||||
});
|
||||
|
||||
const availableProducts = (await getProducts(organizationId)) ?? null;
|
||||
|
||||
if (!!availableProducts && availableProducts?.length <= 1) {
|
||||
throw new Error("You can't delete the last product in the environment.");
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
const membership = organization
|
||||
? await getMembershipByUserIdOrganizationId(session.user.id, organization.id)
|
||||
: null;
|
||||
|
||||
if (!membership) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
if (membership.role === "viewer") {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
if (membership.role === "developer") {
|
||||
if (!!data.name || !!data.organizationId || !!data.environments) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProduct = await updateProduct(productId, data);
|
||||
return updatedProduct;
|
||||
};
|
||||
|
||||
export const deleteProductAction = async (environmentId: string, productId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
const userId = session.user.id;
|
||||
// get the environment from service and check if the user is allowed to update the product
|
||||
let environment: TEnvironment | null = null;
|
||||
|
||||
try {
|
||||
environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("Environment", "Environment not found");
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
const membership = organization ? await getMembershipByUserIdOrganizationId(userId, organization.id) : null;
|
||||
|
||||
if (membership?.role !== "admin" && membership?.role !== "owner") {
|
||||
throw new AuthorizationError("You are not allowed to delete products.");
|
||||
}
|
||||
|
||||
const availableProducts = organization ? await getProducts(organization.id) : null;
|
||||
|
||||
if (!!availableProducts && availableProducts?.length <= 1) {
|
||||
throw new Error("You can't delete the last product in the environment.");
|
||||
}
|
||||
|
||||
const deletedProduct = await deleteProduct(productId);
|
||||
return deletedProduct;
|
||||
};
|
||||
// delete product
|
||||
return await deleteProduct(parsedInput.productId);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro
|
||||
isDeleteDisabled={isDeleteDisabled}
|
||||
isUserAdminOrOwner={isUserAdminOrOwner}
|
||||
product={product}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,14 +10,12 @@ import { Button } from "@formbricks/ui/Button";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
type DeleteProductRenderProps = {
|
||||
environmentId: string;
|
||||
isDeleteDisabled: boolean;
|
||||
isUserAdminOrOwner: boolean;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
export const DeleteProductRender = ({
|
||||
environmentId,
|
||||
isDeleteDisabled,
|
||||
isUserAdminOrOwner,
|
||||
product,
|
||||
@@ -25,12 +23,11 @@ export const DeleteProductRender = ({
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDeleteProduct = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const deletedProduct = await deleteProductAction(environmentId, product.id);
|
||||
if (!!deletedProduct?.id) {
|
||||
const deletedProductActionResult = await deleteProductAction({ productId: product.id });
|
||||
if (deletedProductActionResult?.data) {
|
||||
toast.success("Product deleted successfully.");
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
@@ -12,7 +13,6 @@ import { updateProductAction } from "../actions";
|
||||
|
||||
type EditProductNameProps = {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
isProductNameEditDisabled: boolean;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ type TEditProductName = z.infer<typeof ZProductNameInput>;
|
||||
|
||||
export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
product,
|
||||
environmentId,
|
||||
isProductNameEditDisabled,
|
||||
}) => {
|
||||
const form = useForm<TEditProductName>({
|
||||
@@ -46,16 +45,19 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, { name });
|
||||
const updatedProductResponse = await updateProductAction({
|
||||
productId: product.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (isProductNameEditDisabled) {
|
||||
toast.error("Only Owners, Admins and Editors can perform this action.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!updatedProduct?.id) {
|
||||
if (updatedProductResponse?.data) {
|
||||
toast.success("Product name updated successfully.");
|
||||
form.resetField("name", { defaultValue: updatedProduct.name });
|
||||
form.resetField("name", { defaultValue: updatedProductResponse.data.name });
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProductResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
@@ -11,7 +12,6 @@ import { Input } from "@formbricks/ui/Input";
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type EditWaitingTimeProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ const ZProductRecontactDaysInput = ZProduct.pick({ recontactDays: true });
|
||||
|
||||
type EditWaitingTimeFormValues = z.infer<typeof ZProductRecontactDaysInput>;
|
||||
|
||||
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
|
||||
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product }) => {
|
||||
const form = useForm<EditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: product.recontactDays,
|
||||
@@ -32,10 +32,13 @@ export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, e
|
||||
|
||||
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
|
||||
try {
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, data);
|
||||
if (!!updatedProduct?.id) {
|
||||
const updatedProductResponse = await updateProductAction({ productId: product.id, data });
|
||||
if (updatedProductResponse?.data) {
|
||||
toast.success("Waiting period updated successfully.");
|
||||
form.resetField("recontactDays", { defaultValue: updatedProduct.recontactDays });
|
||||
form.resetField("recontactDays", { defaultValue: updatedProductResponse.data.recontactDays });
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProductResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
|
||||
@@ -57,17 +57,13 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard title="Product Name" description="Change your products name.">
|
||||
<EditProductNameForm
|
||||
environmentId={params.environmentId}
|
||||
product={product}
|
||||
isProductNameEditDisabled={isProductNameEditDisabled}
|
||||
/>
|
||||
<EditProductNameForm product={product} isProductNameEditDisabled={isProductNameEditDisabled} />
|
||||
</SettingsCard>
|
||||
{currentProductChannel !== "link" && (
|
||||
<SettingsCard
|
||||
title="Recontact Waiting Time"
|
||||
description="Control how frequently users can be surveyed across all surveys.">
|
||||
<EditWaitingTimeForm environmentId={params.environmentId} product={product} />
|
||||
<EditWaitingTimeForm product={product} />
|
||||
</SettingsCard>
|
||||
)}
|
||||
<SettingsCard
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { sendInviteMemberEmail } from "@formbricks/email";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { hasOrganizationAuthority } from "@formbricks/lib/auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
@@ -22,20 +25,25 @@ import {
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
|
||||
export const updateOrganizationNameAction = async (organizationId: string, organizationName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
const ZUpdateOrganizationNameAction = z.object({
|
||||
organizationId: z.string(),
|
||||
data: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
});
|
||||
|
||||
const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
|
||||
if (!isUserAuthorized) {
|
||||
throw new AuthenticationError("Not authorized");
|
||||
}
|
||||
|
||||
return await updateOrganization(organizationId, { name: organizationName });
|
||||
};
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.schema(ZUpdateOrganizationNameAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorization({
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
rules: ["organization", "update"],
|
||||
});
|
||||
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
});
|
||||
|
||||
export const deleteInviteAction = async (inviteId: string, organizationId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -50,11 +51,18 @@ export const EditOrganizationName = ({ organization, membershipRole }: EditOrgan
|
||||
try {
|
||||
data.name = data.name.trim();
|
||||
setIsUpdatingOrganization(true);
|
||||
await updateOrganizationNameAction(organization.id, data.name);
|
||||
|
||||
setIsUpdatingOrganization(false);
|
||||
toast.success("Organization name updated successfully.");
|
||||
const updatedOrganizationResponse = await updateOrganizationNameAction({
|
||||
organizationId: organization.id,
|
||||
data: { name: data.name },
|
||||
});
|
||||
|
||||
if (updatedOrganizationResponse?.data) {
|
||||
setIsUpdatingOrganization(false);
|
||||
toast.success("Organization name updated successfully.");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setIsUpdatingOrganization(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
@@ -37,10 +38,18 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
||||
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
const updatedOrg = await updateOrganizationNameAction(organization.id, name);
|
||||
const updatedOrganizationResponse = await updateOrganizationNameAction({
|
||||
organizationId: organization.id,
|
||||
data: { name },
|
||||
});
|
||||
|
||||
toast.success("Organization name updated successfully.");
|
||||
form.reset({ name: updatedOrg.name });
|
||||
if (updatedOrganizationResponse?.data) {
|
||||
toast.success("Organization name updated successfully.");
|
||||
form.reset({ name: updatedOrganizationResponse.data.name });
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/config-tailwind": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/ee": "workspace:*",
|
||||
"@formbricks/email": "workspace:*",
|
||||
@@ -23,7 +24,6 @@
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/config-tailwind": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
@@ -46,6 +46,7 @@
|
||||
"lucide-react": "^0.397.0",
|
||||
"mime": "^4.0.3",
|
||||
"next": "15.0.0-rc.0",
|
||||
"next-safe-action": "^7.1.3",
|
||||
"optional": "^0.1.4",
|
||||
"otplib": "^12.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
@@ -64,12 +65,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@neshca/cache-handler": "^1.3.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@formbricks/eslint-config": "workspace:*"
|
||||
"@types/qrcode": "^1.5.5"
|
||||
}
|
||||
}
|
||||
|
||||
17
packages/lib/actionClient/helper.ts
Normal file
17
packages/lib/actionClient/helper.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const getFormattedErrorMessage = (result) => {
|
||||
let message = "";
|
||||
|
||||
if (result.serverError) {
|
||||
message = result.serverError;
|
||||
} else {
|
||||
const errors = result.validationErrors;
|
||||
message = Object.keys(errors || {})
|
||||
.map((key) => {
|
||||
if (key === "_errors") return errors[key].join(", ");
|
||||
return `${key ? `${key}` : ""}${errors?.[key]?._errors.join(", ")}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
24
packages/lib/actionClient/index.ts
Normal file
24
packages/lib/actionClient/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authOptions } from "../authOptions";
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleReturnedServerError(e) {
|
||||
if (e instanceof ResourceNotFoundError) {
|
||||
return e.message;
|
||||
} else if (e instanceof AuthorizationError) {
|
||||
return e.message;
|
||||
}
|
||||
|
||||
return DEFAULT_SERVER_ERROR_MESSAGE;
|
||||
},
|
||||
});
|
||||
|
||||
export const authenticatedActionClient = actionClient.use(async ({ next }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
return next({ ctx: { user: session.user } });
|
||||
});
|
||||
110
packages/lib/actionClient/permissions.ts
Normal file
110
packages/lib/actionClient/permissions.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ZProductUpdateInput } from "@formbricks/types/product";
|
||||
|
||||
export const Permissions = {
|
||||
owner: {
|
||||
product: {
|
||||
create: true,
|
||||
read: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
},
|
||||
organization: {
|
||||
update: true,
|
||||
},
|
||||
person: {
|
||||
delete: true,
|
||||
},
|
||||
response: {
|
||||
delete: true,
|
||||
},
|
||||
survey: {
|
||||
create: true,
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
product: {
|
||||
create: true,
|
||||
read: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
},
|
||||
organization: {
|
||||
update: true,
|
||||
},
|
||||
person: {
|
||||
delete: true,
|
||||
},
|
||||
response: {
|
||||
delete: true,
|
||||
},
|
||||
survey: {
|
||||
create: true,
|
||||
},
|
||||
},
|
||||
|
||||
editor: {
|
||||
product: {
|
||||
create: true,
|
||||
read: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
},
|
||||
organization: {
|
||||
update: false,
|
||||
},
|
||||
person: {
|
||||
delete: true,
|
||||
},
|
||||
response: {
|
||||
delete: true,
|
||||
},
|
||||
survey: {
|
||||
create: true,
|
||||
},
|
||||
},
|
||||
|
||||
developer: {
|
||||
product: {
|
||||
create: true,
|
||||
read: true,
|
||||
update: ZProductUpdateInput.omit({
|
||||
name: true,
|
||||
}),
|
||||
delete: true,
|
||||
},
|
||||
organization: {
|
||||
update: false,
|
||||
},
|
||||
person: {
|
||||
delete: true,
|
||||
},
|
||||
response: {
|
||||
delete: true,
|
||||
},
|
||||
survey: {
|
||||
create: true,
|
||||
},
|
||||
},
|
||||
|
||||
viewer: {
|
||||
product: {
|
||||
create: false,
|
||||
read: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
},
|
||||
organization: {
|
||||
update: false,
|
||||
},
|
||||
person: {
|
||||
delete: false,
|
||||
},
|
||||
response: {
|
||||
delete: false,
|
||||
},
|
||||
survey: {
|
||||
create: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
64
packages/lib/actionClient/utils.ts
Normal file
64
packages/lib/actionClient/utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { returnValidationErrors } from "next-safe-action";
|
||||
import { ZodIssue, z } from "zod";
|
||||
import { TOperation, TResource } from "@formbricks/types/actionClient";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { getMembershipRole } from "../membership/hooks/actions";
|
||||
import { Permissions } from "./permissions";
|
||||
|
||||
export const getOperationPermissions = (role: TMembershipRole, entity: TResource, operation: TOperation) => {
|
||||
const permission = Permissions[role][entity][operation];
|
||||
|
||||
if (typeof permission === "boolean" && !permission) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
return permission;
|
||||
};
|
||||
|
||||
export const getRoleBasedSchema = <T extends z.ZodRawShape>(
|
||||
schema: z.ZodObject<T>,
|
||||
role: TMembershipRole,
|
||||
entity: TResource,
|
||||
operation: TOperation
|
||||
): z.ZodObject<T> => {
|
||||
const data = getOperationPermissions(role, entity, operation);
|
||||
|
||||
return typeof data === "boolean" && data === true ? schema.strict() : data;
|
||||
};
|
||||
|
||||
export const formatErrors = (errors: ZodIssue[]) => {
|
||||
return {
|
||||
...errors.reduce((acc, error) => {
|
||||
acc[error.path.join(".")] = {
|
||||
_errors: [error.message],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const checkAuthorization = async <T extends z.ZodRawShape>({
|
||||
schema,
|
||||
data,
|
||||
userId,
|
||||
organizationId,
|
||||
rules,
|
||||
}: {
|
||||
schema?: z.ZodObject<T>;
|
||||
data?: z.ZodObject<T>["_output"];
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
rules: [TResource, TOperation];
|
||||
}) => {
|
||||
const role = await getMembershipRole(userId, organizationId);
|
||||
if (schema) {
|
||||
const resultSchema = getRoleBasedSchema(schema, role, ...rules);
|
||||
const parsedResult = resultSchema.safeParse(data);
|
||||
if (!parsedResult.success) {
|
||||
return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues));
|
||||
}
|
||||
} else {
|
||||
getOperationPermissions(role, ...rules);
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { authOptions } from "../../authOptions";
|
||||
import { getOrganizationByEnvironmentId } from "../../organization/service";
|
||||
@@ -21,11 +21,16 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(user.id, organization.id);
|
||||
const currentUserMembership = await getMembershipRole(user.id, organization.id);
|
||||
|
||||
if (!currentUserMembership) {
|
||||
throw new Error("Membership not found");
|
||||
return currentUserMembership;
|
||||
};
|
||||
|
||||
export const getMembershipRole = async (userId: string, organizationId: string) => {
|
||||
const membership = await getMembershipByUserIdOrganizationId(userId, organizationId);
|
||||
if (!membership) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
return currentUserMembership?.role;
|
||||
return membership.role;
|
||||
};
|
||||
|
||||
59
packages/lib/organization/utils.ts
Normal file
59
packages/lib/organization/utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "../environment/service";
|
||||
import { getPerson } from "../person/service";
|
||||
import { getProduct } from "../product/service";
|
||||
import { getResponse } from "../response/service";
|
||||
import { getSurvey } from "../survey/service";
|
||||
|
||||
/**
|
||||
* GET organization ID from RESOURCE ID
|
||||
*/
|
||||
|
||||
export const getOrganizationIdFromProductId = async (productId: string) => {
|
||||
const product = await getProduct(productId);
|
||||
if (!product) {
|
||||
throw new ResourceNotFoundError("product", productId);
|
||||
}
|
||||
|
||||
return product.organizationId;
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromEnvironmentId = async (environmentId: string) => {
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromProductId(environment.productId);
|
||||
return organizationId;
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromSurveyId = async (surveyId: string) => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("survey", surveyId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
|
||||
return organizationId;
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromResponseId = async (responseId: string) => {
|
||||
const response = await getResponse(responseId);
|
||||
if (!response) {
|
||||
throw new ResourceNotFoundError("response", responseId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(response.surveyId);
|
||||
return organizationId;
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromPersonId = async (personId: string) => {
|
||||
const person = await getPerson(personId);
|
||||
if (!person) {
|
||||
throw new ResourceNotFoundError("person", personId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(person.environmentId);
|
||||
return organizationId;
|
||||
};
|
||||
16
packages/types/actionClient.ts
Normal file
16
packages/types/actionClient.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZResource = z.enum([
|
||||
"product",
|
||||
"organization",
|
||||
"environment",
|
||||
"membership",
|
||||
"invite",
|
||||
"response",
|
||||
"survey",
|
||||
"person",
|
||||
]);
|
||||
export type TResource = z.infer<typeof ZResource>;
|
||||
|
||||
export const ZOperation = z.enum(["create", "read", "update", "delete"]);
|
||||
export type TOperation = z.infer<typeof ZOperation>;
|
||||
@@ -1,8 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getOrganizationIdFromResponseId } from "@formbricks/lib/organization/utils";
|
||||
import { canUserAccessResponse } from "@formbricks/lib/response/auth";
|
||||
import { deleteResponse, getResponse } from "@formbricks/lib/response/service";
|
||||
import { canUserModifyResponseNote, canUserResolveResponseNote } from "@formbricks/lib/responseNote/auth";
|
||||
@@ -58,14 +62,21 @@ export const deleteTagOnResponseAction = async (responseId: string, tagId: strin
|
||||
return await deleteTagOnResponse(responseId, tagId);
|
||||
};
|
||||
|
||||
export const deleteResponseAction = async (responseId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
const isAuthorized = await canUserAccessResponse(session.user!.id, responseId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
const ZDeleteResponseAction = z.object({
|
||||
responseId: z.string(),
|
||||
});
|
||||
|
||||
return await deleteResponse(responseId);
|
||||
};
|
||||
export const deleteResponseAction = authenticatedActionClient
|
||||
.schema(ZDeleteResponseAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
|
||||
rules: ["response", "delete"],
|
||||
});
|
||||
|
||||
return await deleteResponse(parsedInput.responseId);
|
||||
});
|
||||
|
||||
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@@ -90,7 +90,7 @@ export const SingleResponseCard = ({
|
||||
if (isViewer) {
|
||||
throw new Error("You are not authorized to perform this action.");
|
||||
}
|
||||
await deleteResponseAction(response.id);
|
||||
await deleteResponseAction({ responseId: response.id });
|
||||
deleteResponse?.(response.id);
|
||||
|
||||
router.refresh();
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { Prisma as prismaClient } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { segmentCache } from "@formbricks/lib/segment/cache";
|
||||
import { createSegment } from "@formbricks/lib/segment/service";
|
||||
@@ -36,243 +40,241 @@ export const duplicateSurveyAction = async (environmentId: string, surveyId: str
|
||||
return duplicatedSurvey;
|
||||
};
|
||||
|
||||
export const copyToOtherEnvironmentAction = async (
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
const ZCopyToOtherEnvironmentAction = z.object({
|
||||
environmentId: z.string(),
|
||||
surveyId: z.string(),
|
||||
targetEnvironmentId: z.string(),
|
||||
});
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
export const copyToOtherEnvironmentAction = authenticatedActionClient
|
||||
.schema(ZCopyToOtherEnvironmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
// check if user has access to source and target environments
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["survey", "create"],
|
||||
});
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.targetEnvironmentId),
|
||||
rules: ["survey", "create"],
|
||||
});
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: parsedInput.surveyId,
|
||||
environmentId: parsedInput.environmentId,
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
select: {
|
||||
id: true,
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
segment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
...(trigger.actionClass.type === "code"
|
||||
? { key: trigger.actionClass.key }
|
||||
: { name: trigger.actionClass.name }),
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
segment: true,
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
...(trigger.actionClass.type === "code"
|
||||
? { key: trigger.actionClass.key }
|
||||
: {
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? structuredClone(trigger.actionClass.noCodeConfig)
|
||||
: undefined,
|
||||
}),
|
||||
: { name: trigger.actionClass.name }),
|
||||
environment: {
|
||||
id: parsedInput.targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: parsedInput.targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
...(trigger.actionClass.type === "code"
|
||||
? { key: trigger.actionClass.key }
|
||||
: {
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? structuredClone(trigger.actionClass.noCodeConfig)
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: parsedInput.targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: parsedInput.targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id;
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
createdBy: undefined,
|
||||
segmentId: undefined,
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: structuredClone(existingSurvey.questions),
|
||||
thankYouCard: structuredClone(existingSurvey.thankYouCard),
|
||||
languages: {
|
||||
create: existingSurvey.languages?.map((surveyLanguage) => ({
|
||||
languageId: surveyLanguage.language.id,
|
||||
default: surveyLanguage.language.id === defaultLanguageId,
|
||||
})),
|
||||
},
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: parsedInput.targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
connect: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
segment: undefined,
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
|
||||
// if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey
|
||||
if (existingSurvey.segment) {
|
||||
if (existingSurvey.segment.isPrivate) {
|
||||
const newInlineSegment = await createSegment({
|
||||
environmentId: parsedInput.environmentId,
|
||||
title: `${newSurvey.id}`,
|
||||
isPrivate: true,
|
||||
surveyId: newSurvey.id,
|
||||
filters: existingSurvey.segment.filters,
|
||||
});
|
||||
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: newSurvey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: newInlineSegment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: newInlineSegment.id,
|
||||
environmentId: newSurvey.environmentId,
|
||||
});
|
||||
} else {
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: newSurvey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: existingSurvey.segment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: existingSurvey.segment.id,
|
||||
environmentId: newSurvey.environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id;
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
createdBy: undefined,
|
||||
segmentId: undefined,
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: structuredClone(existingSurvey.questions),
|
||||
thankYouCard: structuredClone(existingSurvey.thankYouCard),
|
||||
languages: {
|
||||
create: existingSurvey.languages?.map((surveyLanguage) => ({
|
||||
languageId: surveyLanguage.language.id,
|
||||
default: surveyLanguage.language.id === defaultLanguageId,
|
||||
})),
|
||||
},
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
connect: {
|
||||
id: session.user.id,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
segment: undefined,
|
||||
},
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: parsedInput.targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
});
|
||||
|
||||
// if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey
|
||||
if (existingSurvey.segment) {
|
||||
if (existingSurvey.segment.isPrivate) {
|
||||
const newInlineSegment = await createSegment({
|
||||
environmentId,
|
||||
title: `${newSurvey.id}`,
|
||||
isPrivate: true,
|
||||
surveyId: newSurvey.id,
|
||||
filters: existingSurvey.segment.filters,
|
||||
});
|
||||
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: newSurvey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: newInlineSegment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: newInlineSegment.id,
|
||||
environmentId: newSurvey.environmentId,
|
||||
});
|
||||
} else {
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: newSurvey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: existingSurvey.segment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: existingSurvey.segment.id,
|
||||
environmentId: newSurvey.environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
};
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
@@ -85,7 +85,12 @@ export const SurveyDropDownMenu = ({
|
||||
const copyToOtherEnvironment = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
// await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
await copyToOtherEnvironmentAction({
|
||||
environmentId,
|
||||
surveyId,
|
||||
targetEnvironmentId: otherEnvironment.id,
|
||||
});
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
|
||||
147
pnpm-lock.yaml
generated
147
pnpm-lock.yaml
generated
@@ -414,6 +414,9 @@ importers:
|
||||
next:
|
||||
specifier: 15.0.0-rc.0
|
||||
version: 15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524)
|
||||
next-safe-action:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3(@types/json-schema@7.0.15)(next@15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524))(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524)(zod@3.23.8)
|
||||
optional:
|
||||
specifier: ^0.1.4
|
||||
version: 0.1.4
|
||||
@@ -5313,6 +5316,87 @@ packages:
|
||||
'@types/ws@8.5.10':
|
||||
resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
|
||||
|
||||
'@typeschema/core@0.13.2':
|
||||
resolution: {integrity: sha512-pAt0MK249/9szYaoPuvzhSfOd3smrLhhwCCpUNB4onX32mRx5F3lzDIveIYGQkLYRq58xOX5sjoW+n72f/MLLw==}
|
||||
peerDependencies:
|
||||
'@types/json-schema': ^7.0.15
|
||||
peerDependenciesMeta:
|
||||
'@types/json-schema':
|
||||
optional: true
|
||||
|
||||
'@typeschema/main@0.13.10':
|
||||
resolution: {integrity: sha512-ArdFC4GbgdVWWgPKg2tymxx2KHMus3xZ8I2kHwqw/0P4FtWBXCmSNAiBqDtpoXXF8h9cbcm7fVpcs5ftoWT9+A==}
|
||||
peerDependencies:
|
||||
'@typeschema/arktype': 0.13.2
|
||||
'@typeschema/class-validator': 0.1.2
|
||||
'@typeschema/deepkit': 0.13.4
|
||||
'@typeschema/effect': 0.13.4
|
||||
'@typeschema/fastest-validator': 0.1.0
|
||||
'@typeschema/function': 0.13.2
|
||||
'@typeschema/io-ts': 0.13.3
|
||||
'@typeschema/joi': 0.13.3
|
||||
'@typeschema/json': 0.13.3
|
||||
'@typeschema/ow': 0.13.3
|
||||
'@typeschema/runtypes': 0.13.2
|
||||
'@typeschema/superstruct': 0.13.2
|
||||
'@typeschema/suretype': 0.1.0
|
||||
'@typeschema/typebox': 0.13.4
|
||||
'@typeschema/valibot': 0.13.5
|
||||
'@typeschema/valita': 0.1.0
|
||||
'@typeschema/vine': 0.1.0
|
||||
'@typeschema/yup': 0.13.3
|
||||
'@typeschema/zod': 0.13.3
|
||||
peerDependenciesMeta:
|
||||
'@typeschema/arktype':
|
||||
optional: true
|
||||
'@typeschema/class-validator':
|
||||
optional: true
|
||||
'@typeschema/deepkit':
|
||||
optional: true
|
||||
'@typeschema/effect':
|
||||
optional: true
|
||||
'@typeschema/fastest-validator':
|
||||
optional: true
|
||||
'@typeschema/function':
|
||||
optional: true
|
||||
'@typeschema/io-ts':
|
||||
optional: true
|
||||
'@typeschema/joi':
|
||||
optional: true
|
||||
'@typeschema/json':
|
||||
optional: true
|
||||
'@typeschema/ow':
|
||||
optional: true
|
||||
'@typeschema/runtypes':
|
||||
optional: true
|
||||
'@typeschema/superstruct':
|
||||
optional: true
|
||||
'@typeschema/suretype':
|
||||
optional: true
|
||||
'@typeschema/typebox':
|
||||
optional: true
|
||||
'@typeschema/valibot':
|
||||
optional: true
|
||||
'@typeschema/valita':
|
||||
optional: true
|
||||
'@typeschema/vine':
|
||||
optional: true
|
||||
'@typeschema/yup':
|
||||
optional: true
|
||||
'@typeschema/zod':
|
||||
optional: true
|
||||
|
||||
'@typeschema/zod@0.13.3':
|
||||
resolution: {integrity: sha512-p5Hs22WIKkM/vZTAvw5QOLSA0EJ6QBUsQMGUrXlYnTAE2LSR/F5MLsDUb18O6S5VxGjrzU7x3VIznD5qOafJRw==}
|
||||
peerDependencies:
|
||||
zod: ^3.22.4
|
||||
zod-to-json-schema: ^3.22.4
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
zod-to-json-schema:
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@7.13.1':
|
||||
resolution: {integrity: sha512-kZqi+WZQaZfPKnsflLJQCz6Ze9FFSMfXrrIOcyargekQxG37ES7DJNpJUE9Q/X5n3yTIP/WPutVNzgknQ7biLg==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -8933,6 +9017,18 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
next-safe-action@7.1.3:
|
||||
resolution: {integrity: sha512-WWqgoExw2BjiVzTZPryW+rIMRhcrnEhvnKmofCDTNfDD6pmAYXk3f8tM199iTuKGYEHrJDtffs3fggQZlPuMzQ==}
|
||||
engines: {node: '>=18.17'}
|
||||
peerDependencies:
|
||||
next: '>= 14.0.0'
|
||||
react: '>= 18.2.0'
|
||||
react-dom: '>= 18.2.0'
|
||||
zod: '>= 3.0.0'
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
next-seo@6.5.0:
|
||||
resolution: {integrity: sha512-MfzUeWTN/x/rsKp/1n0213eojO97lIl0unxqbeCY+6pAucViHDA8GSLRRcXpgjsSmBxfCFdfpu7LXbt4ANQoNQ==}
|
||||
peerDependencies:
|
||||
@@ -17801,6 +17897,26 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.14.5
|
||||
|
||||
'@typeschema/core@0.13.2(@types/json-schema@7.0.15)':
|
||||
optionalDependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@typeschema/main@0.13.10(@types/json-schema@7.0.15)(@typeschema/zod@0.13.3(@types/json-schema@7.0.15)(zod@3.23.8))':
|
||||
dependencies:
|
||||
'@typeschema/core': 0.13.2(@types/json-schema@7.0.15)
|
||||
optionalDependencies:
|
||||
'@typeschema/zod': 0.13.3(@types/json-schema@7.0.15)(zod@3.23.8)
|
||||
transitivePeerDependencies:
|
||||
- '@types/json-schema'
|
||||
|
||||
'@typeschema/zod@0.13.3(@types/json-schema@7.0.15)(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@typeschema/core': 0.13.2(@types/json-schema@7.0.15)
|
||||
optionalDependencies:
|
||||
zod: 3.23.8
|
||||
transitivePeerDependencies:
|
||||
- '@types/json-schema'
|
||||
|
||||
'@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.10.1
|
||||
@@ -22332,6 +22448,37 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
next-safe-action@7.1.3(@types/json-schema@7.0.15)(next@15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524))(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524)(zod@3.23.8):
|
||||
dependencies:
|
||||
'@typeschema/main': 0.13.10(@types/json-schema@7.0.15)(@typeschema/zod@0.13.3(@types/json-schema@7.0.15)(zod@3.23.8))
|
||||
'@typeschema/zod': 0.13.3(@types/json-schema@7.0.15)(zod@3.23.8)
|
||||
next: 15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524)
|
||||
react: 19.0.0-rc-935180c7e0-20240524
|
||||
react-dom: 19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524)
|
||||
optionalDependencies:
|
||||
zod: 3.23.8
|
||||
transitivePeerDependencies:
|
||||
- '@types/json-schema'
|
||||
- '@typeschema/arktype'
|
||||
- '@typeschema/class-validator'
|
||||
- '@typeschema/deepkit'
|
||||
- '@typeschema/effect'
|
||||
- '@typeschema/fastest-validator'
|
||||
- '@typeschema/function'
|
||||
- '@typeschema/io-ts'
|
||||
- '@typeschema/joi'
|
||||
- '@typeschema/json'
|
||||
- '@typeschema/ow'
|
||||
- '@typeschema/runtypes'
|
||||
- '@typeschema/superstruct'
|
||||
- '@typeschema/suretype'
|
||||
- '@typeschema/typebox'
|
||||
- '@typeschema/valibot'
|
||||
- '@typeschema/valita'
|
||||
- '@typeschema/vine'
|
||||
- '@typeschema/yup'
|
||||
- zod-to-json-schema
|
||||
|
||||
next-seo@6.5.0(next@14.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
next: 14.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
Reference in New Issue
Block a user