diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index ed97cb7d0..0d8eae848 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -21,3 +21,10 @@ export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInpu message: `${field} field can only contain lowercase letters, numbers, and hyphens` }); }; + +export const GenericResourceNameSchema = z + .string() + .trim() + .min(1, { message: "Name must be at least 1 character" }) + .max(64, { message: "Name must be 64 or fewer characters" }) + .regex(/^[a-zA-Z0-9\-_\s]+$/, "Name can only contain alphanumeric characters, dashes, underscores, and spaces"); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index db0008ebe..d117a4303 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -13,7 +13,7 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { slugSchema } from "@app/server/lib/schemas"; +import { GenericResourceNameSchema, slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; @@ -251,7 +251,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ - name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), + name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 8ca105ad4..326b8a497 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -12,6 +12,7 @@ import { import { ORGANIZATIONS } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -330,7 +331,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - name: z.string().trim() + name: GenericResourceNameSchema }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index e95254816..d9196dc88 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -4,6 +4,7 @@ import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError } from "@app/lib/errors"; import { authRateLimit } from "@app/server/config/rateLimiter"; +import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -100,7 +101,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), verifier: z.string().trim(), - organizationName: z.string().trim().min(1), + organizationName: GenericResourceNameSchema, providerAuthToken: z.string().trim().optional().nullish(), attributionSource: z.string().trim().optional(), password: z.string() diff --git a/frontend/src/components/auth/UserInfoStep.tsx b/frontend/src/components/auth/UserInfoStep.tsx index 2b7761d1e..582c2a850 100644 --- a/frontend/src/components/auth/UserInfoStep.tsx +++ b/frontend/src/components/auth/UserInfoStep.tsx @@ -11,6 +11,7 @@ import { encodeBase64 } from "tweetnacl-util"; import { initProjectHelper } from "@app/helpers/project"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; +import { onRequestError } from "@app/hooks/api/reactQuery"; import InputField from "../basic/InputField"; import checkPassword from "../utilities/checks/password/checkPassword"; @@ -206,6 +207,7 @@ export default function UserInfoStep({ incrementStep(); } catch (error) { + onRequestError(error); setIsLoading(false); console.error(error); } diff --git a/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx b/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx index b5712e596..bdf1c5c81 100644 --- a/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx +++ b/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx @@ -8,10 +8,11 @@ import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useCreateOrg, useSelectOrganization } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; const schema = z .object({ - name: z.string().nonempty({ message: "Name is required" }) + name: GenericResourceNameSchema.nonempty({ message: "Name is required" }) }) .required(); @@ -78,7 +79,7 @@ export const CreateOrgModal: FC = ({ isOpen, onClose }) => }; return ( - + { - if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as TApiErrors; - if (serverResponse?.error === ApiErrorTypes.ValidationError) { - createNotification( - { - title: "Validation Error", - type: "error", - text: "Please check the input and try again.", - callToAction: ( - - - - - - - - - - - - - - - {serverResponse.message?.map(({ message, path }) => ( - - - - - ))} - -
FieldIssue
{path.join(".")}{message.toLowerCase()}
-
-
-
- ), - copyActions: [ - { - value: serverResponse.reqId, - name: "Request ID", - label: `Request ID: ${serverResponse.reqId}` - } - ] - }, - { closeOnClick: false } - ); - return; - } - if (serverResponse?.error === ApiErrorTypes.PermissionBoundaryError) { - createNotification( - { - title: "Forbidden Access", - type: "error", - text: `${serverResponse.message}.`, - callToAction: serverResponse?.details?.missingPermissions?.length ? ( - - - - - -
- {serverResponse.details?.missingPermissions?.map((el, index) => { - const hasConditions = Boolean(Object.keys(el.conditions || {}).length); - return ( -
-
- You are not authorized to perform the {el.action} action on the{" "} - {el.subject} resource.{" "} - {hasConditions && - "Your permission does not allow access to the following conditions:"} -
- {hasConditions && ( -
    - {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { - const operators = ( - el.conditions as Record< - string, - | string - | { [K in PermissionConditionOperators]: string | string[] } - > - )[field]; - - const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); - if (typeof operators === "string") { - return ( -
  • - - {formattedFieldName} - {" "} - equal to{" "} - {operators} -
  • - ); - } - - return Object.keys(operators).map((operator, operatorIndex) => ( -
  • - - {formattedFieldName} - {" "} - - { - formatedConditionsOperatorNames[ - operator as PermissionConditionOperators - ] - } - {" "} - - {operators[ - operator as PermissionConditionOperators - ].toString()} - -
  • - )); - })} -
- )} -
- ); - })} -
-
-
- ) : undefined, - copyActions: [ - { - value: serverResponse.reqId, - name: "Request ID", - label: `Request ID: ${serverResponse.reqId}` - } - ] - }, - { closeOnClick: false } - ); - return; - } - if (serverResponse?.error === ApiErrorTypes.ForbiddenError) { - createNotification( - { - title: "Forbidden Access", - type: "error", - text: `${serverResponse.message}.`, - callToAction: serverResponse?.details?.length ? ( - - - - - -
- {serverResponse.details?.map((el, index) => { - const hasConditions = Boolean(Object.keys(el.conditions || {}).length); - return ( -
-
- {el.inverted ? "Cannot" : "Can"}{" "} - - {el.action.toString().replaceAll(",", ", ")} - {" "} - {el.subject.toString()} {hasConditions && "with conditions:"} -
- {hasConditions && ( -
    - {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { - const operators = ( - el.conditions as Record< - string, - | string - | { [K in PermissionConditionOperators]: string | string[] } - > - )[field]; - - const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); - if (typeof operators === "string") { - return ( -
  • - - {formattedFieldName} - {" "} - equal to{" "} - {operators} -
  • - ); - } - - return Object.keys(operators).map((operator, operatorIndex) => ( -
  • - - {formattedFieldName} - {" "} - - { - formatedConditionsOperatorNames[ - operator as PermissionConditionOperators - ] - } - {" "} - - {operators[ - operator as PermissionConditionOperators - ].toString()} - -
  • - )); - })} -
- )} -
- ); - })} -
-
-
- ) : undefined, - copyActions: [ - { - value: serverResponse.reqId, - name: "Request ID", - label: `Request ID: ${serverResponse.reqId}` - } - ] - }, - { closeOnClick: false } - ); - return; - } - createNotification({ - title: "Bad Request", +export const onRequestError = (error: unknown) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as TApiErrors; + if (serverResponse?.error === ApiErrorTypes.ValidationError) { + createNotification( + { + title: "Validation Error", type: "error", - text: `${serverResponse.message}${serverResponse.message?.endsWith(".") ? "" : "."}`, + text: "Please check the input and try again.", + callToAction: ( + + + + + + + + + + + + + + + {serverResponse.message?.map(({ message, path }) => ( + + + + + ))} + +
FieldIssue
{path.join(".")}{message.toLowerCase()}
+
+
+
+ ), copyActions: [ { value: serverResponse.reqId, @@ -287,9 +63,128 @@ export const queryClient = new QueryClient({ label: `Request ID: ${serverResponse.reqId}` } ] - }); - } + }, + { closeOnClick: false } + ); + return; } + if (serverResponse?.error === ApiErrorTypes.ForbiddenError) { + createNotification( + { + title: "Forbidden Access", + type: "error", + text: `${serverResponse.message}.`, + callToAction: serverResponse?.details?.length ? ( + + + + + +
+ {serverResponse.details?.map((el, index) => { + const hasConditions = Boolean(Object.keys(el.conditions || {}).length); + return ( +
+
+ {el.inverted ? "Cannot" : "Can"}{" "} + + {el.action.toString().replaceAll(",", ", ")} + {" "} + {el.subject.toString()} {hasConditions && "with conditions:"} +
+ {hasConditions && ( +
    + {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { + const operators = ( + el.conditions as Record< + string, + | string + | { [K in PermissionConditionOperators]: string | string[] } + > + )[field]; + + const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); + if (typeof operators === "string") { + return ( +
  • + + {formattedFieldName} + {" "} + equal to{" "} + {operators} +
  • + ); + } + + return Object.keys(operators).map((operator, operatorIndex) => ( +
  • + {formattedFieldName}{" "} + + { + formatedConditionsOperatorNames[ + operator as PermissionConditionOperators + ] + } + {" "} + + {operators[operator as PermissionConditionOperators].toString()} + +
  • + )); + })} +
+ )} +
+ ); + })} +
+
+
+ ) : undefined, + copyActions: [ + { + value: serverResponse.reqId, + name: "Request ID", + label: `Request ID: ${serverResponse.reqId}` + } + ] + }, + { closeOnClick: false } + ); + return; + } + createNotification({ + title: "Bad Request", + type: "error", + text: `${serverResponse.message}${serverResponse.message?.endsWith(".") ? "" : "."}`, + copyActions: [ + { + value: serverResponse.reqId, + name: "Request ID", + label: `Request ID: ${serverResponse.reqId}` + } + ] + }); + } +}; + +export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: onRequestError }), defaultOptions: { queries: { diff --git a/frontend/src/lib/schemas/index.ts b/frontend/src/lib/schemas/index.ts index 41b8ace4b..f46500dc8 100644 --- a/frontend/src/lib/schemas/index.ts +++ b/frontend/src/lib/schemas/index.ts @@ -1 +1,13 @@ +import { z } from "zod"; + export * from "./slugSchema"; + +export const GenericResourceNameSchema = z + .string() + .trim() + .min(1, { message: "Name must be at least 1 character" }) + .max(64, { message: "Name must be 64 or fewer characters" }) + .regex( + /^[a-zA-Z0-9\-_\s]+$/, + "Name can only contain alphanumeric characters, dashes, underscores, and spaces" + ); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index bcf3e0446..15ce740bc 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -14,9 +14,10 @@ import { } from "@app/context"; import { isCustomOrgRole } from "@app/helpers/roles"; import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z.string().max(64, "Too long, maximum length is 64 characters"), + name: GenericResourceNameSchema, slug: z .string() .regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens"),