feat: Improve auth & permission system (#2845)

This commit is contained in:
Piyush Gupta
2024-07-09 13:38:20 +05:30
committed by GitHub
parent 6d0bd4a6ed
commit 54accbbeff
26 changed files with 888 additions and 423 deletions

View File

@@ -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");

View File

@@ -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);
});

View File

@@ -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);
}
};

View File

@@ -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;
};

View File

@@ -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);
});

View File

@@ -36,7 +36,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro
isDeleteDisabled={isDeleteDisabled}
isUserAdminOrOwner={isUserAdminOrOwner}
product={product}
environmentId={environmentId}
/>
);
};

View File

@@ -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("/");
}

View File

@@ -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);

View File

@@ -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}`);

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}`);
}

View File

@@ -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"
}
}

View 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;
};

View 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 } });
});

View 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,
},
},
};

View 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);
}
};

View File

@@ -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;
};

View 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;
};

View 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>;

View File

@@ -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);

View File

@@ -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();

View File

@@ -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");

View File

@@ -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
View File

@@ -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)