From 315a35052b47fcf05fa7fcac4846f8cdd2edee41 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Tue, 14 Jul 2026 16:03:54 -0500 Subject: [PATCH 01/23] MARS-1190 Remove `Collaborators` from Projects --- client/src/components/Collaborators/index.tsx | 59 ++--- client/src/pages/view/Project.tsx | 226 ++++++++++-------- server/src/models/Projects.ts | 20 +- server/src/resolvers/Projects.ts | 33 +++ server/src/typedefs.ts | 5 +- templates/project.json | 3 - types/index.d.ts | 2 - 7 files changed, 203 insertions(+), 145 deletions(-) diff --git a/client/src/components/Collaborators/index.tsx b/client/src/components/Collaborators/index.tsx index 24835c66..9287ec99 100644 --- a/client/src/components/Collaborators/index.tsx +++ b/client/src/components/Collaborators/index.tsx @@ -122,33 +122,38 @@ const Collaborators = (props: CollaboratorsProps) => { Collaborators ({props.collaborators.length}) - - - - - setNewCollaborator(event.target.value)} - disabled={!props.editing} - /> - - - - + + + Add Collaborators to this Workspace via email + + + + + + setNewCollaborator(event.target.value)} + disabled={!props.editing} + /> + + + + + { const { id } = useParams(); + const client = useApolloClient(); // Workspace information const { workspace } = useWorkspace(); @@ -81,15 +93,6 @@ const Project = () => { const { onClose: onBlockerClose } = useDisclosure(); const cancelBlockerRef = useRef(null); - // State for current user - const [currentUser, setCurrentUser] = useState(""); - - useEffect(() => { - auth.getSession().then(({ data: session }) => { - if (session?.user) setCurrentUser(session.user.id); - }); - }, []); - // Add Entities const [entitiesOpen, setEntitiesOpen] = useState(false); @@ -117,6 +120,7 @@ const Project = () => { const [projectName, setProjectName] = useState(""); const [projectArchived, setProjectArchived] = useState(false); const [projectEntities, setProjectEntities] = useState([] as string[]); + const [projectEntitiesData, setProjectEntitiesData] = useState([]); const [projectDescription, setProjectDescription] = useState(""); const [projectHistory, setProjectHistory] = useState([] as ProjectHistory[]); @@ -153,8 +157,6 @@ const Project = () => { } }, [projectHistory, historySortOrder, dateFilterApplied, appliedStartDate, appliedEndDate]); - const [projectCollaborators, setProjectCollaborators] = useState([] as string[]); - // Computed values that use preview data when in preview mode const displayProjectName = useMemo(() => { return previewVersion ? previewVersion.name : projectName; @@ -168,10 +170,6 @@ const Project = () => { return previewVersion ? previewVersion.entities : projectEntities; }, [previewVersion, projectEntities]); - const displayProjectCollaborators = useMemo(() => { - return previewVersion ? previewVersion.collaborators : projectCollaborators; - }, [previewVersion, projectCollaborators]); - const displayProjectArchived = useMemo(() => { return previewVersion ? previewVersion.archived : projectArchived; }, [previewVersion, projectArchived]); @@ -183,13 +181,22 @@ const Project = () => { name: previewVersion.name, description: previewVersion.description || "", entities: previewVersion.entities, - collaborators: previewVersion.collaborators, archived: previewVersion.archived, }; } return project; }, [previewVersion, project]); + // Merge fetched Entity data into the displayed rows, keyed by identifier + const entitiesTableData = useMemo(() => { + const entitiesById = new Map(projectEntitiesData.map((entity) => [entity._id, entity])); + return displayProjectEntities.map((_id) => ({ + _id, + description: entitiesById.get(_id)?.description, + attributes: entitiesById.get(_id)?.attributes, + })); + }, [displayProjectEntities, projectEntitiesData]); + // Save message dialog const [saveMessageOpen, setSaveMessageOpen] = useState(false); const [saveMessage, setSaveMessage] = useState(""); @@ -219,25 +226,24 @@ const Project = () => { description owner entities - collaborators history { message author name timestamp version - collaborators created description entities } } - entities { - entities { + projectEntities(_id: $_id) { + _id + name + description + attributes { _id - name } - total } workspace(_id: $workspace) { _id @@ -247,7 +253,7 @@ const Project = () => { `; const { loading, error, data } = useQuery<{ project: ProjectModel; - entities: IGenericItem[]; + projectEntities: EntityModel[]; workspace: IGenericItem; }>(GET_PROJECT_WITH_ENTITIES, { variables: { @@ -257,6 +263,19 @@ const Project = () => { fetchPolicy: "no-cache", }); + // Query for an Entity's table data, used to populate a row as soon as it's added + const GET_ENTITY_TABLE_DATA = gql` + query GetEntityTableData($_id: String) { + entity(_id: $_id) { + _id + description + attributes { + _id + } + } + } + `; + // Mutation to update Project const UPDATE_PROJECT = gql` mutation UpdateProject($project: ProjectUpdateInput, $message: String) { @@ -299,12 +318,15 @@ const Project = () => { setProjectArchived(data.project.archived); setProjectDescription(data.project.description); setProjectEntities(data.project.entities); - setProjectCollaborators(data.project.collaborators || []); } setProjectHistory(data.project.history || []); } + if (data?.projectEntities) { + setProjectEntitiesData(data.projectEntities); + } + if (data?.workspace) { setWorkspaceName(data.workspace.name); } @@ -324,10 +346,22 @@ const Project = () => { } }, [loading, error]); - const addEntities = (): void => { - setProjectEntities([...projectEntities, ...selectedEntities.map((e) => e._id)]); + const addEntities = async (): Promise => { + const addedEntityIds = selectedEntities.map((e) => e._id); + setProjectEntities([...projectEntities, ...addedEntityIds]); setSelectedEntities([]); setEntitiesOpen(false); + + // Fetch table data for the newly added Entities so their rows aren't stuck loading + const results = await Promise.all( + addedEntityIds.map((_id) => + client.query<{ entity: EntityModel }>({ query: GET_ENTITY_TABLE_DATA, variables: { _id } }), + ), + ); + const fetchedEntities = results + .map((result) => result.data?.entity) + .filter((entity): entity is EntityModel => !_.isUndefined(entity)); + setProjectEntitiesData((existing) => [...existing, ...fetchedEntities]); }; /** @@ -358,7 +392,6 @@ const Project = () => { archived: projectArchived, description: projectDescription, owner: project.owner, - collaborators: projectCollaborators || [], created: project.created, entities: projectEntities, history: projectHistory, @@ -374,7 +407,6 @@ const Project = () => { archived: updateData.archived, created: updateData.created, owner: updateData.owner, - collaborators: updateData.collaborators, description: updateData.description, entities: updateData.entities, }), @@ -476,7 +508,6 @@ const Project = () => { setProjectDescription(project.description); setProjectEntities(project.entities); setProjectHistory(project.history); - setProjectCollaborators(project.collaborators); }; /** @@ -492,7 +523,6 @@ const Project = () => { archived: project.archived, created: project.created, owner: project.owner, - collaborators: project.collaborators || [], description: projectVersion.description, entities: projectVersion.entities, history: project.history, @@ -509,7 +539,6 @@ const Project = () => { archived: updateData.archived, created: updateData.created, owner: updateData.owner, - collaborators: updateData.collaborators, description: updateData.description, entities: updateData.entities, }), @@ -541,7 +570,6 @@ const Project = () => { setProjectDescription(updateData.description); setProjectEntities(updateData.entities); setProjectHistory(updateData?.history || []); - setProjectCollaborators(updateData?.collaborators || []); setIsLoaded(true); }; @@ -581,17 +609,14 @@ const Project = () => { }; // Define the columns for Entities listing + const columnHelper = createColumnHelper(); const entitiesColumns = [ - { - id: "entityId", - accessorFn: (row: string) => row, - cell: (info: Cell) => { + columnHelper.accessor("_id", { + cell: (info) => { const entityId = info.getValue(); return ( - - - + {editing ? ( @@ -176,40 +179,75 @@ const Collaborators = (props: CollaboratorsProps) => { } w={"100%"}> {props.collaborators.map((collaborator, index) => ( - - - - Collaborator - + + {/* User Role Display */} + + + + + Role + + + User + + + + + {/* Permissions Display */} + + + Permissions + + + + View + + + Edit + + + - {props.editing && - (collaborator === props.currentUser && props.currentUser !== props.owner ? ( + + {/* Action Buttons */} + {props.editing && !isOwner && ( + + )} + + {props.editing && isOwner && ( + + - ) : ( - collaborator !== props.owner && ( - - ) - ))} + + )} ))} From 2ff350cbbfb6f3878ea8d211a88e9955ce6e9229 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 16 Jul 2026 14:28:41 -0500 Subject: [PATCH 04/23] MARS-1190 Initial structure for RBAC * Updated all types and GraphQL `typedef` file * Added new `usePermissions` hook --- client/src/App.tsx | 10 +- .../components/PermissionsDialog/index.tsx | 244 ++++++++++++++++++ client/src/hooks/useFeatures/index.tsx | 42 --- client/src/hooks/usePermissions/index.tsx | 78 ++++++ client/src/variables.ts | 41 +++ server/src/resolvers/Admin.ts | 60 +---- server/src/resolvers/User.ts | 11 +- server/src/resolvers/Workspaces.ts | 35 ++- server/src/typedefs.ts | 144 ++++++++++- types/index.d.ts | 72 +++++- 10 files changed, 610 insertions(+), 127 deletions(-) create mode 100644 client/src/components/PermissionsDialog/index.tsx delete mode 100644 client/src/hooks/useFeatures/index.tsx create mode 100644 client/src/hooks/usePermissions/index.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 77ae0bc6..e2d94f72 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -57,7 +57,7 @@ import ResetPassword from "@pages/account/ResetPassword"; // Providers import { WorkspaceProvider } from "@hooks/useWorkspace"; -import { FeaturesProvider } from "@hooks/useFeatures"; +import { PermissionsProvider } from "@hooks/usePermissions"; // Theme extension import { theme } from "./styles/theme"; @@ -70,11 +70,11 @@ import { theme } from "./styles/theme"; const Providers = (): React.JSX.Element => { return ( - - + + - - + + ); }; diff --git a/client/src/components/PermissionsDialog/index.tsx b/client/src/components/PermissionsDialog/index.tsx new file mode 100644 index 00000000..6369aec9 --- /dev/null +++ b/client/src/components/PermissionsDialog/index.tsx @@ -0,0 +1,244 @@ +// React +import React from "react"; + +// Existing and custom components +import { Button, Flex, Dialog, Text, CloseButton, Switch } from "@chakra-ui/react"; +import Icon from "@components/Icon"; + +// Existing and custom types +import { PermissionsDialogProps } from "@types"; + +// Utility functions and libraries +// import _ from "lodash"; + +// Hooks +import { usePermissions } from "@hooks/usePermissions"; + +// Variables +import { GLOBAL_STYLES } from "@variables"; + +const PermissionsDialog = (props: PermissionsDialogProps) => { + const { workspacePermissions } = usePermissions(); + + return ( + props.setOpen(event.open)} + size={"lg"} + closeOnEscape + closeOnInteractOutside + > + + + + + + + + + Edit Collaborator Permissions: {props.user} + + + + + props.setOpen(false)} /> + + + + + + {props.isGlobal && ( + + Permissions defined for this User will across all Workspaces. + + )} + {!props.isGlobal && ( + + Permissions defined for this User will only apply to this Workspace. + + )} + + {/* Workspace Permissions */} + + + Workspace Permissions + + + + + + + Edit Workspace + + + + + + + Invite Collaborators + + + + + + {/* Entities Permissions */} + + + Entities + + {/* Create Entities */} + + + + + + Create Entities + + + {/* Edit Entities */} + + + + + + Edit Entities + + + {/* Archive Entities */} + + + + + + Archive Entities + + + + {/* Projects Permissions */} + + + Projects + + {/* Create Entities */} + + + + + + Create Projects + + + {/* Edit Projects */} + + + + + + Edit Projects + + + {/* Archive Projects */} + + + + + + Archive Projects + + + + + + + + + + + + + + + + + ); +}; + +export default PermissionsDialog; diff --git a/client/src/hooks/useFeatures/index.tsx b/client/src/hooks/useFeatures/index.tsx deleted file mode 100644 index 9af0dda2..00000000 --- a/client/src/hooks/useFeatures/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { createContext, useContext, useMemo } from "react"; - -// GraphQL -import { gql } from "@apollo/client"; -import { useQuery } from "@apollo/client/react"; - -// Custom types -import { UserFeatures } from "@types"; - -const GET_CURRENT_USER_FEATURES = gql` - query GetCurrentUserFeatures { - currentUserFeatures { - ai - api - } - } -`; - -type FeaturesContextValue = { - features: UserFeatures; -}; - -const defaultFeatures: UserFeatures = { ai: false, api: false }; - -const FeaturesContext = createContext({ features: defaultFeatures }); - -export const FeaturesProvider = (props: { children: React.JSX.Element }) => { - const { data } = useQuery<{ currentUserFeatures: UserFeatures }>(GET_CURRENT_USER_FEATURES, { - fetchPolicy: "network-only", - }); - - const value = useMemo( - () => ({ features: data?.currentUserFeatures ?? defaultFeatures }), - [data?.currentUserFeatures], - ); - - return {props.children}; -}; - -export const useFeatures = () => { - return useContext(FeaturesContext); -}; diff --git a/client/src/hooks/usePermissions/index.tsx b/client/src/hooks/usePermissions/index.tsx new file mode 100644 index 00000000..9c0b3a01 --- /dev/null +++ b/client/src/hooks/usePermissions/index.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useContext, useMemo } from "react"; + +// GraphQL +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; + +// Custom types +import { UserAllPermissions, UserGlobalPermissions, UserWorkspacePermissions } from "@types"; + +// Variables +import { DEFAULT_GLOBAL_PERMISSIONS, DEFAULT_WORKSPACE_PERMISSIONS } from "@variables"; + +const GET_USER_PERMISSIONS = gql` + query GetUserPermissions { + userAllPermissions { + workspace { + workspaces { + edit + invite + } + entities { + create + edit + archive + } + projects { + create + edit + archive + } + templates { + create + edit + archive + } + } + global { + application { + import + scan + ai + api + } + workspaces { + create + invite + } + } + } + } +`; + +type PermissionsContextValue = { + workspacePermissions: UserWorkspacePermissions; + globalPermissions: UserGlobalPermissions; +}; + +const PermissionsContext = createContext({} as PermissionsContextValue); + +export const PermissionsProvider = (props: { children: React.JSX.Element }) => { + const { data } = useQuery<{ userAllPermissions: UserAllPermissions }>(GET_USER_PERMISSIONS, { + fetchPolicy: "network-only", + }); + + const value = useMemo( + () => ({ + workspacePermissions: data?.userAllPermissions.workspace || DEFAULT_WORKSPACE_PERMISSIONS, + globalPermissions: data?.userAllPermissions.global || DEFAULT_GLOBAL_PERMISSIONS, + }), + [data?.userAllPermissions], + ); + + return {props.children}; +}; + +export const usePermissions = () => { + return useContext(PermissionsContext); +}; diff --git a/client/src/variables.ts b/client/src/variables.ts index cd967b18..e7c66bfc 100644 --- a/client/src/variables.ts +++ b/client/src/variables.ts @@ -1,6 +1,47 @@ /** * Specify important application-wide variables */ + +// Custom types +import { UserGlobalPermissions, UserWorkspacePermissions } from "@types"; + +// Default Workspace permissions, mirrors server variables +export const DEFAULT_WORKSPACE_PERMISSIONS: UserWorkspacePermissions = { + workspaces: { + edit: false, + invite: false, + }, + entities: { + create: false, + edit: false, + archive: false, + }, + templates: { + create: false, + edit: false, + archive: false, + }, + projects: { + create: false, + edit: false, + archive: false, + }, +}; + +// Default global permissions, mirrors server variables +export const DEFAULT_GLOBAL_PERMISSIONS: UserGlobalPermissions = { + application: { + import: false, + scan: false, + ai: false, + api: false, + }, + workspaces: { + create: false, + invite: false, + }, +}; + // URL of the client application export const APP_URL = import.meta.env.NODE_ENV !== "production" ? "http://127.0.0.1:8080" : "https://app.metadatify.com"; diff --git a/server/src/resolvers/Admin.ts b/server/src/resolvers/Admin.ts index 4514a66f..4dc1fdc4 100644 --- a/server/src/resolvers/Admin.ts +++ b/server/src/resolvers/Admin.ts @@ -1,12 +1,4 @@ -import { - AdminMetrics, - AdminUser, - AdminWorkspace, - Context, - IResolverParent, - IResponseMessage, - UserFeatures, -} from "@types"; +import { AdminMetrics, AdminUser, AdminWorkspace, Context, IResolverParent, IResponseMessage } from "@types"; import { GraphQLError } from "graphql/index"; // Models @@ -26,14 +18,6 @@ const requireAdmin = (context: Context) => { export const AdminResolvers = { Query: { - currentUserFeatures: async ( - _parent: IResolverParent, - _args: Record, - context: Context, - ): Promise => { - return await Admin.getCurrentUserFeatures(context.user); - }, - adminMetrics: async ( _parent: IResolverParent, _args: Record, @@ -63,36 +47,18 @@ export const AdminResolvers = { }, Mutation: { - setUserRole: async ( - _parent: IResolverParent, - args: { _id: string; role: string }, - context: Context, - ): Promise => { - requireAdmin(context); - - if (!["user", "admin"].includes(args.role)) { - return { success: false, message: "Invalid role" }; - } - - const result = await Admin.setUserRole(args._id, args.role); - if (result.success) { - audit("admin.role_changed", context.user, { targetUserId: args._id, role: args.role }); - } - return result; - }, - - setUserFeatures: async ( - _parent: IResolverParent, - args: { _id: string; features: Partial }, - context: Context, - ): Promise => { - requireAdmin(context); - const result = await Admin.setUserFeatures(args._id, args.features); - if (result.success) { - audit("admin.features_changed", context.user, { targetUserId: args._id }); - } - return result; - }, + // setUserFeatures: async ( + // _parent: IResolverParent, + // args: { _id: string; features: Partial }, + // context: Context, + // ): Promise => { + // requireAdmin(context); + // const result = await Admin.setUserFeatures(args._id, args.features); + // if (result.success) { + // audit("admin.features_changed", context.user, { targetUserId: args._id }); + // } + // return result; + // }, setBanStatus: async ( _parent: IResolverParent, diff --git a/server/src/resolvers/User.ts b/server/src/resolvers/User.ts index 4cd6da1e..ef27bb07 100644 --- a/server/src/resolvers/User.ts +++ b/server/src/resolvers/User.ts @@ -1,6 +1,7 @@ -import { IResolverParent, IResponseMessage, ResponseData, UserModel } from "@types"; +import { Context, IResolverParent, IResponseMessage, ResponseData, UserAllPermissions, UserModel } from "@types"; // Models +import { Admin } from "@models/Admin"; import { User } from "@models/User"; // Email @@ -27,6 +28,14 @@ export const UserResolvers = { userByOrcid: async (_parent: IResolverParent, args: { orcid: string }): Promise> => { return await User.getByOrcid(args.orcid); }, + + userAllPermissions: async ( + _parent: IResolverParent, + _args: Record, + context: Context, + ): Promise => { + return await Admin.getCurrentUserPermissions(context.user, context.workspace); + }, }, Mutation: { // Create a User diff --git a/server/src/resolvers/Workspaces.ts b/server/src/resolvers/Workspaces.ts index c3a8428f..d8541db0 100644 --- a/server/src/resolvers/Workspaces.ts +++ b/server/src/resolvers/Workspaces.ts @@ -8,6 +8,7 @@ import { WorkspaceMetrics, WorkspaceModel, IResolverParent, + Collaborator, } from "@types"; import _ from "lodash"; import { GraphQLError } from "graphql/index"; @@ -33,7 +34,10 @@ export const WorkspacesResolvers = { // Access control if (workspaces.length > 0) { return workspaces.filter((workspace) => { - return _.isEqual(workspace.owner, context.user) || _.includes(workspace.collaborators, context.user); + return ( + _.isEqual(workspace.owner, context.user) || + workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) + ); }); } @@ -59,7 +63,8 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, context.user) || _.isEqual(workspace.owner, context.user)) + (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || + _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator return workspace; @@ -90,7 +95,8 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, context.user) || _.isEqual(workspace.owner, context.user)) + (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || + _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.getEntities(args._id); @@ -122,7 +128,8 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, context.user) || _.isEqual(workspace.owner, context.user)) + (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || + _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.getProjects(args._id); @@ -154,7 +161,8 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, context.user) || _.isEqual(workspace.owner, context.user)) + (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || + _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.getActivity(args._id); @@ -220,7 +228,8 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, context.user) || _.isEqual(workspace.owner, context.user)) + (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || + _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.update(args.workspace); @@ -230,13 +239,17 @@ export const WorkspacesResolvers = { if (newCollaborators.length > 0) { const workspaceUrl = `${CLIENT_URL}/workspaces/${args.workspace._id}`; await Promise.allSettled( - newCollaborators.map(async (collaboratorId) => { - const collaborator = await User.getOne(collaboratorId); - if (collaborator) { + newCollaborators.map(async (collaborator) => { + const collaboratorResult = await User.getOne(collaborator._id); + if (collaboratorResult) { await sendEmail({ - to: collaborator.email, + to: collaboratorResult.email, subject: `You've been added to "${args.workspace.name}" on Metadatify`, - html: templates.workspaceCollaboratorAdded(collaborator.name, args.workspace.name, workspaceUrl), + html: templates.workspaceCollaboratorAdded( + collaboratorResult.name, + args.workspace.name, + workspaceUrl, + ), }); } }), diff --git a/server/src/typedefs.ts b/server/src/typedefs.ts index c8c8d14f..d1ca6259 100644 --- a/server/src/typedefs.ts +++ b/server/src/typedefs.ts @@ -60,18 +60,128 @@ export const typedefs = `#graphql projects: Int templates: Int } - - # "UserFeatures" type - type UserFeatures { + + # "UserGlobalApplicationPermissions" type + type UserGlobalApplicationPermissions { + import: Boolean + scan: Boolean ai: Boolean api: Boolean } - # "UserFeaturesInput" type - input UserFeaturesInput { + # "UserGlobalApplicationPermissionsInput" type + input UserGlobalApplicationPermissionsInput { + import: Boolean + scan: Boolean ai: Boolean api: Boolean } + + # "UserGlobalWorkspacePermissions" type + type UserGlobalWorkspacePermissions { + create: Boolean + invite: Boolean + } + + # "UserGlobalWorkspacePermissionsInput" type + input UserGlobalWorkspacePermissionsInput { + create: Boolean + invite: Boolean + } + + # "UserGlobalPermissions" type + type UserGlobalPermissions { + application: UserGlobalApplicationPermissions + workspaces: UserGlobalWorkspacePermissions + } + + # "UserGlobalPermissionsInput" type + input UserGlobalPermissionsInput { + application: UserGlobalApplicationPermissionsInput + workspaces: UserGlobalWorkspacePermissionsInput + } + + # "UserWorkspaceWorkspacesPermissions" type + type UserWorkspaceWorkspacesPermissions { + edit: Boolean + invite: Boolean + } + + # "UserWorkspaceWorkspacesPermissionsInput" type + input UserWorkspaceWorkspacesPermissionsInput { + edit: Boolean + invite: Boolean + } + + # "UserWorkspaceEntitiesPermissions" type + type UserWorkspaceEntitiesPermissions { + create: Boolean + edit: Boolean + archive: Boolean + } + + # "UserWorkspaceEntitiesPermissionsInput" type + input UserWorkspaceEntitiesPermissionsInput { + create: Boolean + edit: Boolean + archive: Boolean + } + + # "UserWorkspaceProjectsPermissions" type + type UserWorkspaceProjectsPermissions { + create: Boolean + edit: Boolean + archive: Boolean + } + + # "UserWorkspaceProjectsPermissionsInput" type + input UserWorkspaceProjectsPermissionsInput { + create: Boolean + edit: Boolean + archive: Boolean + } + + # "UserWorkspaceTemplatesPermissions" type + type UserWorkspaceTemplatesPermissions { + create: Boolean + edit: Boolean + archive: Boolean + } + + # "UserWorkspaceTemplatesPermissionsInput" type + input UserWorkspaceTemplatesPermissionsInput { + create: Boolean + edit: Boolean + archive: Boolean + } + + # "UserWorkspacePermissions" type + type UserWorkspacePermissions { + workspaces: UserWorkspaceWorkspacesPermissions + entities: UserWorkspaceEntitiesPermissions + projects: UserWorkspaceProjectsPermissions + templates: UserWorkspaceTemplatesPermissions + } + + # "UserWorkspacePermissionsInput" type + input UserWorkspacePermissionsInput { + workspaces: UserWorkspaceWorkspacesPermissionsInput + entities: UserWorkspaceEntitiesPermissionsInput + projects: UserWorkspaceProjectsPermissionsInput + templates: UserWorkspaceTemplatesPermissionsInput + } + + # "UserAllPermissions" type + type UserAllPermissions { + workspace: UserWorkspacePermissions + global: UserGlobalPermissions + } + + # "UserAllPermissionsInput" type + input UserAllPermissionsInput { + workspace: UserWorkspacePermissionsInput + global: UserGlobalPermissionsInput + } # "AdminWorkspace" type type AdminWorkspace { @@ -91,7 +201,7 @@ export const typedefs = `#graphql email: String role: String workspaces: Int - features: UserFeatures + permissions: UserGlobalPermissions banned: Boolean lastLogin: String } @@ -388,6 +498,18 @@ export const typedefs = `#graphql options: OptionsInput file: [Upload]! } + + # "Collaborator" type + type Collaborator { + _id: String + permissions: UserWorkspacePermissions + } + + # "CollaboratorInput" type + input CollaboratorInput { + _id: String + permissions: UserWorkspacePermissionsInput + } # "Workspace" type type Workspace { @@ -397,7 +519,7 @@ export const typedefs = `#graphql public: Boolean description: String owner: String - collaborators: [String] + collaborators: [Collaborator] entities: [String] projects: [String] templates: [String] @@ -410,7 +532,7 @@ export const typedefs = `#graphql description: String public: Boolean owner: String - collaborators: [String] + collaborators: [CollaboratorInput] entities: [String] projects: [String] templates: [String] @@ -424,7 +546,7 @@ export const typedefs = `#graphql public: Boolean description: String owner: String - collaborators: [String] + collaborators: [CollaboratorInput] entities: [String] projects: [String] templates: [String] @@ -589,13 +711,13 @@ export const typedefs = `#graphql adminMetrics: AdminMetrics adminUsers: [AdminUser] adminWorkspaces: [AdminWorkspace] - currentUserFeatures: UserFeatures # User queries users: [User] user(_id: String): User userByEmail(email: String): ResponseDataString userByOrcid(orcid: String): ResponseDataString + userAllPermissions: UserAllPermissions # Project queries projects(limit: Int, archived: Boolean): [Project] @@ -681,7 +803,7 @@ export const typedefs = `#graphql # Admin mutations setUserRole(_id: String, role: String): ResponseMessage - setUserFeatures(_id: String, features: UserFeaturesInput): ResponseMessage + setUserPermissions(_id: String, permissions: UserGlobalPermissionsInput): ResponseMessage setBanStatus(_id: String, banned: Boolean): ResponseMessage # User mutations diff --git a/types/index.d.ts b/types/index.d.ts index e9b5b258..1c635cc0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -130,12 +130,17 @@ export type IValueSelectData = { options: string[]; }; +export type Collaborator = { + _id: string; + permissions: UserWorkspacePermissions; // Workspace-scoped permissions +}; + // "Collaborators" component props export type CollaboratorsProps = { editing: boolean; currentUser: string; owner: string; - collaborators: string[]; + collaborators: Collaborator[]; setCollaborators: (value: React.SetStateAction) => void; }; @@ -258,7 +263,7 @@ export type IWorkspace = { owner: string; public: boolean; description: string; - collaborators: string[]; + collaborators: Collaborator[]; entities: string[]; projects: string[]; templates: string[]; @@ -729,8 +734,9 @@ export type IUser = { updatedAt: string; // better-auth: Last updated lastLogin: string; // better-auth: Timestamp of last login api_keys: string; // better-auth: Stored as a JSON string - role: string; // better-auth admin: "user" or "admin" - features: UserFeatures; // Account features such as AI search or API access + role: string; // better-auth: "user" or "admin" + banned: boolean; // better-auth: Overarching access status + permissions: UserGlobalPermissions; // Global permissions such as AI search or API access account_orcid: string; // ORCiD if connected hasSeenWalkthrough?: boolean; // If user has seen or skipped the initial walkthrough completedProfile?: boolean; // `false` until third-party signup profile is completed @@ -740,6 +746,57 @@ export type UserModel = IUser & { _id: string; }; +// User Workspace permissions structure +export type UserWorkspacePermissions = { + workspaces: { + edit: boolean; + invite: boolean; + }; + entities: { + create: boolean; + edit: boolean; + archive: boolean; + }; + projects: { + create: boolean; + edit: boolean; + archive: boolean; + }; + templates: { + create: boolean; + edit: boolean; + archive: boolean; + }; +}; + +// User global permissions structure +export type UserGlobalPermissions = { + application: { + import: boolean; + scan: boolean; + ai: boolean; + api: boolean; + }; + workspaces: { + create: boolean; + invite: boolean; + }; +}; + +export type UserAllPermissions = { + workspace: UserWorkspacePermissions; + global: UserGlobalPermissions; +}; + +// Permissions Dialog props +export type PermissionsDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + user: string; + isGlobal: boolean; // Define if modifying "global" permissions or just for the Workspace + workspace?: string; // Specify the Workspace if modifying Workspace permissions +}; + // Metrics export type IContentMetrics = { all: number; @@ -772,18 +829,13 @@ export type AdminMetrics = { templates: number; }; -export type UserFeatures = { - ai: boolean; - api: boolean; -}; - export type AdminUser = { _id: string; name: string; email: string; role: string; workspaces: number; - features: UserFeatures; + permissions: UserGlobalPermissions; banned: boolean; lastLogin: string; }; From b33c5f17cefec74a322e3e5f6eb04c8bd3d9bc07 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 16 Jul 2026 14:37:25 -0500 Subject: [PATCH 05/23] MARS-1190 Implement baseline global permissions --- client/src/components/ImportDialog/index.tsx | 8 +- client/src/components/Navigation/index.tsx | 83 ++++++----- client/src/components/SearchBox/index.tsx | 22 ++- client/src/pages/Search.tsx | 17 ++- client/src/pages/view/User.tsx | 14 +- client/test/components/SearchBox.test.tsx | 7 +- server/src/models/Admin.ts | 146 +++++++++++++++---- 7 files changed, 210 insertions(+), 87 deletions(-) diff --git a/client/src/components/ImportDialog/index.tsx b/client/src/components/ImportDialog/index.tsx index dda8f0eb..db7f954a 100644 --- a/client/src/components/ImportDialog/index.tsx +++ b/client/src/components/ImportDialog/index.tsx @@ -68,7 +68,7 @@ import { usePostHog } from "posthog-js/react"; import { ACCEPTED_IMPORTS_ENTITIES, ACCEPTED_IMPORTS_TEMPLATES, GLOBAL_STYLES } from "@variables"; // Hooks -import { useFeatures } from "@hooks/useFeatures"; +import { usePermissions } from "@hooks/usePermissions"; // Variables const JSON_MIME_TYPE = "application/json"; @@ -199,7 +199,9 @@ const IMPORT_TEMPLATE_JSON = gql` const ImportDialog = (props: ImportDialogProps) => { // Posthog const posthog = usePostHog(); - const { features } = useFeatures(); + + // Permissions + const { globalPermissions } = usePermissions(); // Operation and button states const [importLoading, setImportLoading] = useState(false); @@ -859,7 +861,7 @@ const ImportDialog = (props: ImportDialogProps) => { // Fetch AI column mapping suggestions when columns become available useEffect(() => { - if (!features.ai || columns.length === 0 || !isSpreadsheetFile(fileType)) return; + if (!globalPermissions.application.ai || columns.length === 0 || !isSpreadsheetFile(fileType)) return; const fetchSuggestions = async () => { setIsSuggesting(true); diff --git a/client/src/components/Navigation/index.tsx b/client/src/components/Navigation/index.tsx index 3d4b4941..5a5c406a 100644 --- a/client/src/components/Navigation/index.tsx +++ b/client/src/components/Navigation/index.tsx @@ -7,6 +7,7 @@ import Icon from "@components/Icon"; import ImportDialog from "@components/ImportDialog"; import ScanDialog from "@components/ScanDialog"; import ReportDialog from "@components/ReportDialog"; +import Tooltip from "@components/Tooltip"; import WorkspaceSwitcher from "@components/WorkspaceSwitcher"; // Routing and navigation @@ -18,8 +19,9 @@ import _ from "lodash"; // Events import { usePostHog } from "posthog-js/react"; -// Workspace context +// Hooks import { useWorkspace } from "@hooks/useWorkspace"; +import { usePermissions } from "@hooks/usePermissions"; // Variables import { GLOBAL_STYLES } from "@variables"; @@ -32,6 +34,9 @@ const Navigation = () => { const navigate = useNavigate(); const location = useLocation(); + // Permissions + const { globalPermissions } = usePermissions(); + // Workspace context value const { workspace } = useWorkspace(); @@ -206,45 +211,49 @@ const Navigation = () => { - + + + - + + + diff --git a/client/src/components/SearchBox/index.tsx b/client/src/components/SearchBox/index.tsx index 810577d0..1a65c9a8 100644 --- a/client/src/components/SearchBox/index.tsx +++ b/client/src/components/SearchBox/index.tsx @@ -35,13 +35,15 @@ import { ignoreAbort } from "@lib/util"; import { GLOBAL_STYLES } from "@variables"; // Hooks -import { useFeatures } from "@hooks/useFeatures"; +import { usePermissions } from "@hooks/usePermissions"; // Limit the number of results shown const MAX_RESULTS = 5; const SearchBox = () => { - const { features } = useFeatures(); + // Permissions + const { globalPermissions } = usePermissions(); + const navigate = useNavigate(); const containerRef = useRef(null); const [inputWidth, setInputWidth] = useState(undefined); @@ -162,7 +164,7 @@ const SearchBox = () => { let searchQuery = query; let isBuilder = false; - if (features.ai) { + if (globalPermissions.application.ai) { const translation = await runTranslateSearch({ variables: { query } }).catch(ignoreAbort); if (!translation) { @@ -254,18 +256,22 @@ const SearchBox = () => { : undefined} + startElement={ + globalPermissions.application.ai ? ( + + ) : undefined + } > { setQuery(event.target.value); setOpen(false); @@ -286,7 +292,7 @@ const SearchBox = () => { data-search-button size={"xs"} rounded={"md"} - colorPalette={features.ai ? "purple" : "green"} + colorPalette={globalPermissions.application.ai ? "purple" : "green"} disabled={query === ""} loading={isSearching} loadingText={"Searching..."} diff --git a/client/src/pages/Search.tsx b/client/src/pages/Search.tsx index fba936fd..cc428328 100644 --- a/client/src/pages/Search.tsx +++ b/client/src/pages/Search.tsx @@ -25,9 +25,9 @@ import SearchQueryBuilder from "@components/SearchQueryBuilder"; import Tooltip from "@components/Tooltip"; import { toaster } from "@components/Toast"; -// Custom hooks +// Hooks import { useBreakpoint } from "@hooks/useBreakpoint"; -import { useFeatures } from "@hooks/useFeatures"; +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; // Existing and custom types @@ -57,9 +57,12 @@ import { GLOBAL_STYLES } from "@variables"; import { usePostHog } from "posthog-js/react"; const Search = () => { - const [query, setQuery] = useState(""); const posthog = usePostHog(); - const { features } = useFeatures(); + + // Permissions + const { globalPermissions } = usePermissions(); + + const [query, setQuery] = useState(""); const { workspace } = useWorkspace(); const [workspaceName, setWorkspaceName] = useState(""); @@ -81,8 +84,8 @@ const Search = () => { const [isTranslating, setIsTranslating] = useState(false); useEffect(() => { - if (!features.ai) setIsAISearch(false); - }, [features.ai]); + if (!globalPermissions.application.ai) setIsAISearch(false); + }, [globalPermissions.application.ai]); // Include archived Entities const [showArchived, setShowArchived] = useState(false); @@ -870,7 +873,7 @@ const Search = () => { }} /> - {features.ai && ( + {globalPermissions.application.ai && ( + + + ), header: "Permissions", - meta: { minWidth: 300 } as ColumnMeta, + meta: { minWidth: 200 } as ColumnMeta, }), ]; @@ -336,6 +324,12 @@ const Admin = () => { return ( + { public timestamp description - collaborators + collaborators { + _id + } } } `; @@ -175,10 +177,10 @@ const Workspace = () => { }, []); // State for Workspace collaborators - const [collaborators, setCollaborators] = useState([] as string[]); + const [collaborators, setCollaborators] = useState([]); // State for Workspace Counters - const [counters, setCounters] = useState([] as CounterModel[]); + const [counters, setCounters] = useState([]); // State for Workspace privacy const [isPublic, setIsPublic] = useState(false); diff --git a/client/src/variables.ts b/client/src/variables.ts index e7c66bfc..403ed679 100644 --- a/client/src/variables.ts +++ b/client/src/variables.ts @@ -38,7 +38,6 @@ export const DEFAULT_GLOBAL_PERMISSIONS: UserGlobalPermissions = { }, workspaces: { create: false, - invite: false, }, }; diff --git a/server/src/lib/util.ts b/server/src/lib/util.ts index 8e71edd1..66915e23 100644 --- a/server/src/lib/util.ts +++ b/server/src/lib/util.ts @@ -1,3 +1,6 @@ +// Custom types +import { Collaborator } from "@types"; + // Utility libraries and functions import { nanoid } from "nanoid"; @@ -13,3 +16,19 @@ export const getIdentifier = ( ): string => { return `${type.slice(0, 1)}${nanoid(9)}`; }; + +/** + * Utility function to search a list of `Collaborator` objects and locate a specific + * User identifier if it is present + * @param {string} _id Identifier of User to search for + * @param {Collaborator[]} collaborators Collection of `Collaborator` instances + * @return `true` if located, `false` if not + */ +export const isCollaborator = (_id: string, collaborators: Collaborator[]): boolean => { + for (const collaborator of collaborators) { + if (collaborator._id === _id) { + return true; + } + } + return false; +}; diff --git a/server/src/models/Admin.ts b/server/src/models/Admin.ts index 3ee3a464..65b64441 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -55,7 +55,6 @@ export const DEFAULT_GLOBAL_PERMISSIONS: UserGlobalPermissions = { }, workspaces: { create: false, - invite: false, }, }; @@ -104,7 +103,6 @@ export class Admin { }, workspaces: { create: user.permissions.workspaces.create, - invite: user.permissions.workspaces.invite, }, }; @@ -150,7 +148,7 @@ export class Admin { }); }; - static getCurrentUserPermissions = async (_id: string, workspace: string): Promise => { + static getUserAllPermissions = async (_id: string, workspace: string): Promise => { const userResult = await getDatabase().collection(USERS_COLLECTION).findOne({ _id: _id }); const workspaceResult = await getDatabase() .collection(WORKSPACES_COLLECTION) @@ -181,6 +179,17 @@ export class Admin { }; }; + static getUserGlobalPermissions = async (_id: string): Promise => { + const userResult = await getDatabase().collection(USERS_COLLECTION).findOne({ _id: _id }); + + // Check that the User was located, if not return default permissions + if (!userResult) { + return DEFAULT_GLOBAL_PERMISSIONS; + } + + return userResult.permissions; + }; + static setUserGlobalPermissions = async ( _id: string, permissions: Partial, diff --git a/server/src/models/User.ts b/server/src/models/User.ts index 0b8c6305..d68c39b7 100644 --- a/server/src/models/User.ts +++ b/server/src/models/User.ts @@ -1,5 +1,5 @@ // Custom types -import { APIKey, IResponseMessage, ResponseData, UserModel } from "@types"; +import { APIKey, IResponseMessage, ResponseData, UserGlobalPermissions, UserModel } from "@types"; import _ from "lodash"; import dayjs from "dayjs"; @@ -170,6 +170,35 @@ export class User { }; }; + static updateGlobalPermissions = async ( + _id: string, + permissions: UserGlobalPermissions, + ): Promise => { + const user = await this.getOne(_id); + + if (_.isNull(user)) { + return { + success: false, + message: "User not found", + }; + } + + const update: { $set: UserModel } = { + $set: { + ...user, + permissions, + }, + }; + + const response = await getDatabase().collection(USERS_COLLECTION).updateOne({ _id: _id }, update); + const successStatus = response.modifiedCount === 1 || response.matchedCount === 1; + + return { + success: successStatus, + message: successStatus ? "Updated User permissions successfully" : "Unable to update User permissions", + }; + }; + static create = async (user: UserModel): Promise => { const response = await getDatabase().collection(USERS_COLLECTION).insertOne(user); diff --git a/server/src/models/Workspaces.ts b/server/src/models/Workspaces.ts index e7ea31ea..5c21d4d2 100644 --- a/server/src/models/Workspaces.ts +++ b/server/src/models/Workspaces.ts @@ -12,7 +12,7 @@ import { // Utility functions and libraries import { getDatabase } from "@connectors/database"; -import { getIdentifier } from "@lib/util"; +import { getIdentifier, isCollaborator } from "@lib/util"; import dayjs from "dayjs"; import _ from "lodash"; @@ -347,6 +347,6 @@ export class Workspaces { return false; } - return workspaceResult.owner === user || _.includes(workspaceResult.collaborators, user); + return workspaceResult.owner === user || isCollaborator(user, workspaceResult.collaborators); }; } diff --git a/server/src/resolvers/Admin.ts b/server/src/resolvers/Admin.ts index 4dc1fdc4..305e1842 100644 --- a/server/src/resolvers/Admin.ts +++ b/server/src/resolvers/Admin.ts @@ -47,19 +47,6 @@ export const AdminResolvers = { }, Mutation: { - // setUserFeatures: async ( - // _parent: IResolverParent, - // args: { _id: string; features: Partial }, - // context: Context, - // ): Promise => { - // requireAdmin(context); - // const result = await Admin.setUserFeatures(args._id, args.features); - // if (result.success) { - // audit("admin.features_changed", context.user, { targetUserId: args._id }); - // } - // return result; - // }, - setBanStatus: async ( _parent: IResolverParent, args: { _id: string; banned: boolean }, diff --git a/server/src/resolvers/User.ts b/server/src/resolvers/User.ts index ef27bb07..a7795946 100644 --- a/server/src/resolvers/User.ts +++ b/server/src/resolvers/User.ts @@ -1,4 +1,12 @@ -import { Context, IResolverParent, IResponseMessage, ResponseData, UserAllPermissions, UserModel } from "@types"; +import { + Context, + IResolverParent, + IResponseMessage, + ResponseData, + UserAllPermissions, + UserGlobalPermissions, + UserModel, +} from "@types"; // Models import { Admin } from "@models/Admin"; @@ -34,7 +42,15 @@ export const UserResolvers = { _args: Record, context: Context, ): Promise => { - return await Admin.getCurrentUserPermissions(context.user, context.workspace); + return await Admin.getUserAllPermissions(context.user, context.workspace); + }, + + userGlobalPermissions: async ( + _parent: IResolverParent, + args: { _id?: string }, + context: Context, + ): Promise => { + return await Admin.getUserGlobalPermissions(args._id ?? context.user); }, }, Mutation: { @@ -48,6 +64,14 @@ export const UserResolvers = { return await User.update(args.user); }, + // Update a User's global permissions + updateUserGlobalPermissions: async ( + _parent: IResolverParent, + args: { _id: string; permissions: UserGlobalPermissions }, + ): Promise => { + return await User.updateGlobalPermissions(args._id, args.permissions); + }, + // Send a report issue email to the admin reportIssue: async ( _parent: IResolverParent, diff --git a/server/src/resolvers/Workspaces.ts b/server/src/resolvers/Workspaces.ts index d8541db0..5ca57854 100644 --- a/server/src/resolvers/Workspaces.ts +++ b/server/src/resolvers/Workspaces.ts @@ -8,7 +8,6 @@ import { WorkspaceMetrics, WorkspaceModel, IResolverParent, - Collaborator, } from "@types"; import _ from "lodash"; import { GraphQLError } from "graphql/index"; @@ -20,11 +19,14 @@ import { User } from "@models/User"; // Email import { sendEmail, templates } from "@lib/email"; -const CLIENT_URL = process.env.NODE_ENV === "production" ? "https://app.metadatify.com" : "http://127.0.0.1:8080"; - // Posthog import { PostHogClient } from "@lib/posthog"; +// Utility functions +import { isCollaborator } from "@lib/util"; + +const CLIENT_URL = process.env.NODE_ENV === "production" ? "https://app.metadatify.com" : "http://127.0.0.1:8080"; + export const WorkspacesResolvers = { Query: { // Retrieve all Workspaces @@ -34,10 +36,7 @@ export const WorkspacesResolvers = { // Access control if (workspaces.length > 0) { return workspaces.filter((workspace) => { - return ( - _.isEqual(workspace.owner, context.user) || - workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) - ); + return _.isEqual(workspace.owner, context.user) || isCollaborator(context.user, workspace.collaborators); }); } @@ -63,8 +62,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || - _.isEqual(workspace.owner, context.user)) + (isCollaborator(context.user, workspace.collaborators) || _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator return workspace; @@ -95,8 +93,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || - _.isEqual(workspace.owner, context.user)) + (isCollaborator(context.user, workspace.collaborators) || _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.getEntities(args._id); @@ -128,8 +125,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || - _.isEqual(workspace.owner, context.user)) + (isCollaborator(context.user, workspace.collaborators) || _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.getProjects(args._id); @@ -161,8 +157,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || - _.isEqual(workspace.owner, context.user)) + (isCollaborator(context.user, workspace.collaborators) || _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.getActivity(args._id); @@ -228,8 +223,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === context.user) || - _.isEqual(workspace.owner, context.user)) + (isCollaborator(context.user, workspace.collaborators) || _.isEqual(workspace.owner, context.user)) ) { // Check if user is a Workspace owner or collaborator const result = await Workspaces.update(args.workspace); diff --git a/server/src/typedefs.ts b/server/src/typedefs.ts index d1ca6259..f6e41b85 100644 --- a/server/src/typedefs.ts +++ b/server/src/typedefs.ts @@ -80,13 +80,11 @@ export const typedefs = `#graphql # "UserGlobalWorkspacePermissions" type type UserGlobalWorkspacePermissions { create: Boolean - invite: Boolean } # "UserGlobalWorkspacePermissionsInput" type input UserGlobalWorkspacePermissionsInput { create: Boolean - invite: Boolean } # "UserGlobalPermissions" type @@ -718,6 +716,7 @@ export const typedefs = `#graphql userByEmail(email: String): ResponseDataString userByOrcid(orcid: String): ResponseDataString userAllPermissions: UserAllPermissions + userGlobalPermissions(_id: String): UserGlobalPermissions # Project queries projects(limit: Int, archived: Boolean): [Project] @@ -809,6 +808,7 @@ export const typedefs = `#graphql # User mutations createUser(user: UserInput): ResponseMessage updateUser(user: UserInput): ResponseMessage + updateUserGlobalPermissions(_id: String, permissions: UserGlobalPermissionsInput): ResponseMessage reportIssue(description: String, path: String, userName: String, userId: String, userEmail: String, consoleErrors: [String]): ResponseMessage # Template mutations diff --git a/types/index.d.ts b/types/index.d.ts index 1c635cc0..e6b1858e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -779,7 +779,6 @@ export type UserGlobalPermissions = { }; workspaces: { create: boolean; - invite: boolean; }; }; From 8ed6de976cb63090daf26e7ffe691a7445bd8d57 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Fri, 17 Jul 2026 11:54:46 -0500 Subject: [PATCH 07/23] MARS-1190 Fix confusing types --- client/src/components/ImportDialog/index.tsx | 2 +- client/src/components/Navigation/index.tsx | 8 +-- .../components/PermissionsDialog/index.tsx | 24 ++++----- client/src/components/SearchBox/index.tsx | 14 +++-- client/src/hooks/usePermissions/index.tsx | 16 +++--- client/src/pages/Search.tsx | 6 +-- client/src/pages/account/Admin.tsx | 15 +----- client/src/pages/view/User.tsx | 8 +-- client/src/variables.ts | 4 +- server/src/models/Admin.ts | 26 +++++----- server/src/resolvers/User.ts | 18 +++---- server/src/typedefs.ts | 52 ++++++++++--------- types/index.d.ts | 6 +-- 13 files changed, 93 insertions(+), 106 deletions(-) diff --git a/client/src/components/ImportDialog/index.tsx b/client/src/components/ImportDialog/index.tsx index db7f954a..e3c07647 100644 --- a/client/src/components/ImportDialog/index.tsx +++ b/client/src/components/ImportDialog/index.tsx @@ -861,7 +861,7 @@ const ImportDialog = (props: ImportDialogProps) => { // Fetch AI column mapping suggestions when columns become available useEffect(() => { - if (!globalPermissions.application.ai || columns.length === 0 || !isSpreadsheetFile(fileType)) return; + if (!globalPermissions.features.ai || columns.length === 0 || !isSpreadsheetFile(fileType)) return; const fetchSuggestions = async () => { setIsSuggesting(true); diff --git a/client/src/components/Navigation/index.tsx b/client/src/components/Navigation/index.tsx index 5a5c406a..79a2f273 100644 --- a/client/src/components/Navigation/index.tsx +++ b/client/src/components/Navigation/index.tsx @@ -211,7 +211,7 @@ const Navigation = () => { - + + + @@ -162,19 +176,26 @@ const Create = () => { - + + diff --git a/client/src/pages/create/Entity.tsx b/client/src/pages/create/Entity.tsx index 932bfdb7..79254da8 100644 --- a/client/src/pages/create/Entity.tsx +++ b/client/src/pages/create/Entity.tsx @@ -54,6 +54,9 @@ import { useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; // Authentication import { auth } from "@lib/auth"; +// Hooks +import { usePermissions } from "@hooks/usePermissions"; + // Posthog import { usePostHog } from "posthog-js/react"; @@ -63,6 +66,9 @@ import { GLOBAL_STYLES } from "@variables"; const Entity = () => { const posthog = usePostHog(); + // Permissions + const { workspacePermissions } = usePermissions(); + const [pageState, setPageState] = useState("start" as "start" | "attributes" | "relationships"); const pageSteps = [ { title: "Start", description: "Basic information" }, @@ -98,6 +104,11 @@ const Entity = () => { const [addAttributesOpen, setAddAttributesOpen] = useState(false); const getUser = async () => { + // If the User does not have Workspace permissions, direct to `/unauthorized` + if (!workspacePermissions.entities.create && window.location.pathname !== "/unauthorized") { + window.location.href = "/unauthorized"; + } + const sessionResponse = await auth.getSession(); if (sessionResponse.error || !sessionResponse.data) { toaster.create({ diff --git a/client/src/pages/create/Project.tsx b/client/src/pages/create/Project.tsx index 74fa57fd..4d07f211 100644 --- a/client/src/pages/create/Project.tsx +++ b/client/src/pages/create/Project.tsx @@ -41,6 +41,9 @@ import { Cell } from "@tanstack/react-table"; // Authentication import { auth } from "@lib/auth"; +// Hooks +import { usePermissions } from "@hooks/usePermissions"; + // Posthog import { usePostHog } from "posthog-js/react"; @@ -50,6 +53,9 @@ import { GLOBAL_STYLES } from "@variables"; const Project = () => { const posthog = usePostHog(); + // Permissions + const { workspacePermissions } = usePermissions(); + const [informationOpen, setInformationOpen] = useState(false); const [name, setName] = useState(""); const [created, setCreated] = useState(dayjs(Date.now()).format("YYYY-MM-DDTHH:mm")); @@ -57,6 +63,11 @@ const Project = () => { const [description, setDescription] = useState(""); const getUser = async () => { + // If the User does not have Workspace permissions, direct to `/unauthorized` + if (!workspacePermissions.entities.create && window.location.pathname !== "/unauthorized") { + window.location.href = "/unauthorized"; + } + const sessionResponse = await auth.getSession(); if (sessionResponse.error || !sessionResponse.data) { toaster.create({ diff --git a/client/src/pages/view/Entities.tsx b/client/src/pages/view/Entities.tsx index d1d5b7e5..691be14b 100644 --- a/client/src/pages/view/Entities.tsx +++ b/client/src/pages/view/Entities.tsx @@ -35,6 +35,7 @@ import { useNavigate } from "react-router-dom"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; import { useWorkspace } from "@hooks/useWorkspace"; +import { usePermissions } from "@hooks/usePermissions"; // GraphQL imports import { gql } from "@apollo/client"; @@ -54,6 +55,9 @@ import { GLOBAL_STYLES } from "@variables"; const Entities = () => { const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + const { workspace } = useWorkspace(); const [workspaceName, setWorkspaceName] = useState(""); @@ -356,10 +360,22 @@ const Entities = () => { - + + + diff --git a/client/src/pages/view/Entity.tsx b/client/src/pages/view/Entity.tsx index 4a5da799..04b0fde6 100644 --- a/client/src/pages/view/Entity.tsx +++ b/client/src/pages/view/Entity.tsx @@ -82,6 +82,7 @@ import { useParams, useNavigate, useBlocker } from "react-router-dom"; // Contexts and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; import { useWorkspace } from "@hooks/useWorkspace"; +import { usePermissions } from "@hooks/usePermissions"; // Authentication import { auth } from "@lib/auth"; @@ -97,6 +98,9 @@ const Entity = () => { const { breakpoint } = useBreakpoint(); const posthog = usePostHog(); + // Permissions + const { workspacePermissions } = usePermissions(); + // Navigation and routing const navigate = useNavigate(); const blocker = useBlocker( @@ -1204,20 +1208,26 @@ const Entity = () => { - + + )} {entityArchived ? ( - + + ) : ( - + + )} {/* Version history */} @@ -1684,17 +1719,27 @@ const Entity = () => { Preview - + + diff --git a/client/src/pages/view/Project.tsx b/client/src/pages/view/Project.tsx index 7bb3051b..4195024d 100644 --- a/client/src/pages/view/Project.tsx +++ b/client/src/pages/view/Project.tsx @@ -60,6 +60,7 @@ import { useQuery, useMutation, useApolloClient } from "@apollo/client/react"; import { useParams, useNavigate, useBlocker } from "react-router-dom"; // Hooks +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; // Utility functions and libraries @@ -81,6 +82,9 @@ const Project = () => { const { id } = useParams(); const client = useApolloClient(); + // Permissions + const { workspacePermissions } = usePermissions(); + // Workspace information const { workspace } = useWorkspace(); const [workspaceName, setWorkspaceName] = useState(""); @@ -745,20 +749,26 @@ const Project = () => { - + + + + + ) : ( {editing && ( @@ -897,19 +919,25 @@ const Project = () => { )} - + + )} @@ -1198,17 +1226,27 @@ const Project = () => { Preview - + + diff --git a/client/src/pages/view/Projects.tsx b/client/src/pages/view/Projects.tsx index 09a9f76a..be26217d 100644 --- a/client/src/pages/view/Projects.tsx +++ b/client/src/pages/view/Projects.tsx @@ -39,6 +39,7 @@ import { useNavigate } from "react-router-dom"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; // Apollo client imports @@ -70,6 +71,9 @@ const GET_PROJECTS = gql` const Projects = () => { const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + const { workspace } = useWorkspace(); const [workspaceName, setWorkspaceName] = useState(""); @@ -300,10 +304,22 @@ const Projects = () => { - + + + diff --git a/server/src/models/Admin.ts b/server/src/models/Admin.ts index 0bf0e1c3..39374810 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -252,8 +252,6 @@ export class Admin { }; } - console.info(_id, workspace, permissions); - const collaboratorResult = workspaceResult.collaborators.find( (collaborator: Collaborator) => collaborator._id === _id, ); From 19a793732c618d029eb4011738ee065016c85c8b Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Mon, 20 Jul 2026 14:53:30 -0500 Subject: [PATCH 10/23] MARS-1190 Extend to Templates --- client/src/components/Error/index.tsx | 2 +- client/src/pages/Unauthorized.tsx | 32 ++---- client/src/pages/create/Create.tsx | 31 +++--- client/src/pages/create/Entity.tsx | 36 ++++--- client/src/pages/create/Project.tsx | 55 +++++----- client/src/pages/create/Template.tsx | 40 +++++-- client/src/pages/view/Template.tsx | 143 ++++++++++++++++---------- client/src/pages/view/Templates.tsx | 24 ++++- 8 files changed, 220 insertions(+), 143 deletions(-) diff --git a/client/src/components/Error/index.tsx b/client/src/components/Error/index.tsx index 4d0f5b04..b433ebd6 100644 --- a/client/src/components/Error/index.tsx +++ b/client/src/components/Error/index.tsx @@ -19,7 +19,7 @@ const Error = ({ error }: ErrorProps) => { { { - Access Denied + Unauthorized - You no longer have access to this Workspace. + You do not have permission to access this feature or resource in this Workspace. - You may have been removed as a collaborator, or the Workspace may no longer exist. Please contact the - Workspace owner if you believe this is a mistake. + This is typically due to insufficient permissions within this Workspace, or removal as a Collaborator from + this Workspace. - Use the Workspace switcher to select a Workspace you currently have access to. - - - - - Additional Information: + + If you believe this is in error, contact the Workspace owner, or use the Workspace switcher to select a + different Workspace. - - - {"UNAUTHORIZED"} - - diff --git a/client/src/pages/create/Create.tsx b/client/src/pages/create/Create.tsx index e28dd910..98c4404f 100644 --- a/client/src/pages/create/Create.tsx +++ b/client/src/pages/create/Create.tsx @@ -245,19 +245,26 @@ const Create = () => { - + + diff --git a/client/src/pages/create/Entity.tsx b/client/src/pages/create/Entity.tsx index 79254da8..3a4db26d 100644 --- a/client/src/pages/create/Entity.tsx +++ b/client/src/pages/create/Entity.tsx @@ -941,22 +941,28 @@ const Entity = () => { )} - + + { - + + {/* Add Entities dialog */} @@ -437,7 +444,7 @@ const Project = () => { - {/* Information modialogdal */} + {/* Information dialog */} setInformationOpen(event.open)} diff --git a/client/src/pages/create/Template.tsx b/client/src/pages/create/Template.tsx index a3bd9292..6cdd2c00 100644 --- a/client/src/pages/create/Template.tsx +++ b/client/src/pages/create/Template.tsx @@ -18,9 +18,10 @@ import { import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; import Icon from "@components/Icon"; -import Values from "@components/Values"; -import { UnsavedChangesDialog } from "@components/UnsavedChangesDialog"; import { toaster } from "@components/Toast"; +import Tooltip from "@components/Tooltip"; +import { UnsavedChangesDialog } from "@components/UnsavedChangesDialog"; +import Values from "@components/Values"; // Existing and custom types import { IAttribute, IValue, ResponseData } from "@types"; @@ -39,6 +40,9 @@ import dayjs from "dayjs"; // Authentication context import { auth } from "@lib/auth"; +// Hooks +import { usePermissions } from "@hooks/usePermissions"; + // Posthog import { usePostHog } from "posthog-js/react"; @@ -48,6 +52,9 @@ import { GLOBAL_STYLES } from "@variables"; const Template = () => { const posthog = usePostHog(); + // Permissions + const { workspacePermissions } = usePermissions(); + const [informationOpen, setInformationOpen] = useState(false); const [name, setName] = useState(""); const [owner, setOwner] = useState(""); @@ -57,6 +64,11 @@ const Template = () => { const [isSubmitting, setIsSubmitting] = useState(false); const getUser = async () => { + // If the User does not have Workspace permissions, direct to `/unauthorized` + if (!workspacePermissions.templates.create && window.location.pathname !== "/unauthorized") { + window.location.href = "/unauthorized"; + } + const sessionResponse = await auth.getSession(); if (sessionResponse.error || !sessionResponse.data) { toaster.create({ @@ -483,16 +495,22 @@ const Template = () => { - + + { const { id } = useParams(); const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + // Workspace information const { workspace } = useWorkspace(); const [workspaceName, setWorkspaceName] = useState(""); @@ -545,20 +549,26 @@ const Template = () => { - + + + + ) : ( {editing && ( @@ -688,19 +711,25 @@ const Template = () => { )} - + + )} @@ -990,17 +1019,27 @@ const Template = () => { Preview - + + diff --git a/client/src/pages/view/Templates.tsx b/client/src/pages/view/Templates.tsx index 9a1b011b..bd55cbbd 100644 --- a/client/src/pages/view/Templates.tsx +++ b/client/src/pages/view/Templates.tsx @@ -45,6 +45,7 @@ import { useQuery } from "@apollo/client/react"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; // Variables @@ -53,6 +54,9 @@ import { GLOBAL_STYLES } from "@variables"; const Templates = () => { const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + const { workspace } = useWorkspace(); const [workspaceName, setWorkspaceName] = useState(""); @@ -307,10 +311,22 @@ const Templates = () => { - + + + From 932382f6f1aff087a860d40b6072fe445a1b6a54 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Tue, 28 Jul 2026 13:21:47 -0500 Subject: [PATCH 11/23] MARS-1190 Update permissions views * Update `Collaborators` component * Update `Admin` and `Workspace` view pages * Update server to handle `permissions` parameter within the authentication structure * Add utility function to parse permissions stored as JSON string --- client/src/components/Collaborators/index.tsx | 254 ++++++++++-------- .../components/PermissionsDialog/index.tsx | 136 +++++++--- client/src/lib/auth.ts | 4 + client/src/pages/account/Admin.tsx | 14 +- client/src/pages/view/Project.tsx | 10 +- client/src/pages/view/Workspace.tsx | 155 +++++++---- server/src/lib/auth.ts | 8 + server/src/lib/util.ts | 15 +- server/src/models/Admin.ts | 24 +- types/index.d.ts | 3 +- 10 files changed, 414 insertions(+), 209 deletions(-) diff --git a/client/src/components/Collaborators/index.tsx b/client/src/components/Collaborators/index.tsx index 33b848de..c020d0b0 100644 --- a/client/src/components/Collaborators/index.tsx +++ b/client/src/components/Collaborators/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Button, EmptyState, Field, Fieldset, Flex, Input, Separator, Stack, Tag, Text } from "@chakra-ui/react"; +import { Button, EmptyState, Field, Fieldset, Flex, Input, Link, Separator, Stack, Text } from "@chakra-ui/react"; // Custom components import ActorTag from "@components/ActorTag"; @@ -8,17 +8,20 @@ import PermissionsDialog from "@components/PermissionsDialog"; import { toaster } from "@components/Toast"; // Custom types -import { Collaborator, CollaboratorsProps, ResponseData } from "@types"; +import { Collaborator, CollaboratorsProps, ResponseData, UserModel } from "@types"; // GraphQL imports import { gql } from "@apollo/client"; -import { useLazyQuery } from "@apollo/client/react"; +import { useLazyQuery, useQuery } from "@apollo/client/react"; // Utility functions import { isValidEmail, ignoreAbort, isCollaborator } from "@lib/util"; // Variables -import { GLOBAL_STYLES } from "@variables"; +import { DEFAULT_WORKSPACE_PERMISSIONS, GLOBAL_STYLES } from "@variables"; + +// Hooks +import { usePermissions } from "@hooks/usePermissions"; // Analytics import { usePostHog } from "posthog-js/react"; @@ -33,8 +36,36 @@ const GET_USER_BY_EMAIL = gql` } `; +const GET_USER_EMAIL = gql` + query GetUserEmail($_id: String) { + user(_id: $_id) { + email + } + } +`; + +// Displays a Collaborator's email address, filling the space beside their name and actions +const CollaboratorEmail = (props: { userId: string }) => { + const { loading, data } = useQuery<{ user: Partial }>(GET_USER_EMAIL, { + variables: { _id: props.userId }, + fetchPolicy: "network-only", + }); + + return ( + + + {loading ? "" : data?.user.email} + + + ); +}; + const Collaborators = (props: CollaboratorsProps) => { const posthog = usePostHog(); + + // Permissions + const { workspacePermissions } = usePermissions(); + const [newCollaborator, setNewCollaborator] = useState(""); const [validEmail, setValidEmail] = useState(false); @@ -45,6 +76,7 @@ const Collaborators = (props: CollaboratorsProps) => { // `PermissionsDialog` state const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false); + const [permissionsDialogUser, setPermissionsDialogUser] = useState(""); const [getCollaboratorUserId, { loading: collaboratorQueryLoading, error }] = useLazyQuery<{ userByEmail: ResponseData; @@ -79,10 +111,12 @@ const Collaborators = (props: CollaboratorsProps) => { closable: true, }); } else if (result.data) { - const collaborator = result.data.userByEmail.data; - if ( - !props.collaborators.find((existingCollaborator: Collaborator) => existingCollaborator._id === collaborator) - ) { + const collaborator: Collaborator = { + _id: result.data.userByEmail.data, + permissions: DEFAULT_WORKSPACE_PERMISSIONS, + }; + + if (!isCollaborator(collaborator._id, props.collaborators)) { posthog.capture("client.collaborator.added"); props.setCollaborators((collaborators) => [...collaborators, collaborator]); } else { @@ -108,7 +142,7 @@ const Collaborators = (props: CollaboratorsProps) => { const handleRemoveCollaborator = (collaborator: string) => { posthog.capture("client.collaborator.removed"); - props.setCollaborators((collaborators) => collaborators.filter((c) => c !== collaborator)); + props.setCollaborators((collaborators) => collaborators.filter((c) => c._id !== collaborator)); }; return ( @@ -130,39 +164,43 @@ const Collaborators = (props: CollaboratorsProps) => { Collaborators ({props.collaborators.length}) - - - Invite Collaborators to this Workspace via email - - - - - - setNewCollaborator(event.target.value)} - disabled={!props.editing || !isOwner} - /> - - - - + + {workspacePermissions.administration.invite && ( + + + Invite Collaborators to this Workspace via email + + + + + + setNewCollaborator(event.target.value)} + disabled={!props.editing} + /> + + + + + - + )} + { ) : ( - } w={"100%"}> + } w={"100%"}> {props.collaborators.map((collaborator, index) => ( - - + + + + Collaborator + + - {/* Permissions Display */} - - - Permissions - - - - View - - - Edit - + {/* Email, filling the space beside the Collaborator */} + + + Email + + + + {/* Action Buttons */} + {props.editing && ( + + {!isOwner && props.currentUser === collaborator._id && ( + + )} + + + + {isOwner && ( + + )} - + )} - - {/* Action Buttons */} - {props.editing && !isOwner && ( - - )} - - {props.editing && isOwner && ( - - - - - - - - )} ))} )} + + {permissionsDialogUser && ( + + )} ); }; diff --git a/client/src/components/PermissionsDialog/index.tsx b/client/src/components/PermissionsDialog/index.tsx index 69b4024a..9abf6ee7 100644 --- a/client/src/components/PermissionsDialog/index.tsx +++ b/client/src/components/PermissionsDialog/index.tsx @@ -2,9 +2,10 @@ import React, { useEffect, useState } from "react"; // Existing and custom components -import { Button, Flex, Dialog, Text, CloseButton, Switch } from "@chakra-ui/react"; +import { Button, Flex, Dialog, Text, CloseButton, Switch, Separator } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import Icon from "@components/Icon"; +import { Information } from "@components/Label"; import { toaster } from "@components/Toast"; // Existing and custom types @@ -20,15 +21,28 @@ import { gql } from "@apollo/client"; import { GLOBAL_STYLES } from "@variables"; import { useLazyQuery, useMutation } from "@apollo/client/react"; +// Read-only display of a single permission, used when the Dialog is not `editable` +const PermissionStatus = (props: { label: string; granted: boolean }) => ( + + + {props.label} + + + +); + const PermissionsDialog = (props: PermissionsDialogProps) => { const { globalPermissions, workspacePermissions } = usePermissions(); + // Default to editable so existing callers (eg. Admin) are unaffected + const editable = props.editable ?? true; + // Global permissions state for current user const [featuresImport, setFeaturesImport] = useState(globalPermissions.features.import); const [featuresScan, setFeaturesScan] = useState(globalPermissions.features.scan); const [featuresAI, setFeaturesAI] = useState(globalPermissions.features.ai); const [featuresAPI, setFeaturesAPI] = useState(globalPermissions.features.api); - const [workspaceCreate, setWorkspaceCreate] = useState(globalPermissions.workspaces.create); + const [workspaceCreate] = useState(globalPermissions.workspaces.create); // Workspace-specific permissions for the specified user const [workspaceEdit, setWorkspaceEdit] = useState(workspacePermissions.administration.edit); @@ -298,7 +312,7 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { scrollBehavior={"inside"} placement={"center"} onOpenChange={(event) => props.setOpen(event.open)} - size={"lg"} + size={!editable || props.isGlobal ? "md" : "lg"} closeOnEscape closeOnInteractOutside > @@ -316,7 +330,7 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { - Edit {props.isGlobal ? "Global" : "Workspace"} Permissions + {editable ? "Edit" : "View"} {props.isGlobal ? "Global" : "Workspace"} Permissions @@ -329,19 +343,18 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { {props.isGlobal && ( - - Permissions defined for this User will across all Workspaces. - + )} - {!props.isGlobal && ( - - Permissions defined for this User will only apply to this Workspace. - + {!props.isGlobal && editable && ( + + )} + {!editable && ( + )} {/* Global Permissions */} {props.isGlobal && ( - + {/* Application Permissions */} { rounded={"md"} border={GLOBAL_STYLES.border.style} borderColor={GLOBAL_STYLES.border.color} - w={"50%"} h={"fit-content"} > { )} + {/* Workspace Permissions preview, shown instead of the toggles when not editable */} + {!props.isGlobal && !editable && ( + + + + Workspace + + + + + + + + + + Entities + + + + + + + + + + + Projects + + + + + + + + + + + Templates + + + + + + + )} + {/* Workspace Permissions */} - {!props.isGlobal && ( + {!props.isGlobal && editable && ( {/* Workspace Permissions */} @@ -720,29 +787,36 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { borderTop={"1px"} borderColor={"gray.200"} > - - + + {editable && ( + + )} diff --git a/client/src/pages/view/Workspace.tsx b/client/src/pages/view/Workspace.tsx index f30d7c6a..a4ffb9b4 100644 --- a/client/src/pages/view/Workspace.tsx +++ b/client/src/pages/view/Workspace.tsx @@ -26,12 +26,14 @@ import { useNavigate } from "react-router-dom"; // Utility functions and libraries import _ from "lodash"; +import { removeTypename } from "@lib/util"; // Authentication import { auth } from "@lib/auth"; // Contexts and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; // Variables @@ -40,6 +42,9 @@ import { GLOBAL_STYLES } from "@variables"; const Workspace = () => { const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + // Query to get a Workspace const GET_WORKSPACE = gql` query GetWorkspace($_id: String) { @@ -52,6 +57,27 @@ const Workspace = () => { description collaborators { _id + permissions { + administration { + edit + invite + } + entities { + create + edit + archive + } + projects { + create + edit + archive + } + templates { + create + edit + archive + } + } } } } @@ -262,14 +288,14 @@ const Workspace = () => { const handleUpdateClick = async () => { await updateWorkspace({ variables: { - workspace: { + workspace: removeTypename({ _id: workspace, name: name, description: description, owner: owner, public: isPublic, collaborators: collaborators, - }, + }), }, }); @@ -434,17 +460,24 @@ const Workspace = () => { - + + + + + + - - + {workspacePermissions.administration.edit && ( + + + + + )} @@ -726,6 +775,7 @@ const Workspace = () => { rounded={"md"} placeholder={"Name"} value={name} + disabled={!workspacePermissions.administration.edit} onChange={(event) => setName(event.target.value)} /> @@ -791,6 +841,7 @@ const Workspace = () => { value={description} size={"xs"} h={"100%"} + disabled={!workspacePermissions.administration.edit} onChange={(event) => setDescription(event.target.value)} /> diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts index d9390996..ebd2f872 100644 --- a/server/src/lib/auth.ts +++ b/server/src/lib/auth.ts @@ -14,6 +14,9 @@ import { User } from "@models/User"; // Email import { sendEmail, templates } from "./email"; +// Variables +import { DEFAULT_GLOBAL_PERMISSIONS } from "@models/Admin"; + /** * Get ORCiD OAuth configuration based on environment */ @@ -173,6 +176,11 @@ export const auth = betterAuth({ type: "boolean", defaultValue: false, }, + permissions: { + type: "json", + defaultValue: DEFAULT_GLOBAL_PERMISSIONS, + input: false, + }, }, }, }); diff --git a/server/src/lib/util.ts b/server/src/lib/util.ts index 66915e23..4a865a28 100644 --- a/server/src/lib/util.ts +++ b/server/src/lib/util.ts @@ -1,5 +1,5 @@ // Custom types -import { Collaborator } from "@types"; +import { Collaborator, UserGlobalPermissions } from "@types"; // Utility libraries and functions import { nanoid } from "nanoid"; @@ -32,3 +32,16 @@ export const isCollaborator = (_id: string, collaborators: Collaborator[]): bool } return false; }; + +/** + * Utility function to parse a JSON-formatted or expected `UserGlobalPermissions` object + * representing the user's global permissions. + * + * Note: Modifying outside of better-auth means that `permissions` is stored as a JSON string, + * mirroring `api_keys` + * @param {UserGlobalPermissions | string} permissions Either a JSON string or `UserGlobalPermissions` instance + * @return {UserGlobalPermissions} + */ +export const parseGlobalPermissions = (permissions: UserGlobalPermissions | string): UserGlobalPermissions => { + return typeof permissions === "string" ? JSON.parse(permissions) : permissions; +}; diff --git a/server/src/models/Admin.ts b/server/src/models/Admin.ts index 39374810..74ec4c29 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -19,6 +19,7 @@ import { User } from "./User"; import { getDatabase } from "@connectors/database"; // Utility functions and libraries +import { parseGlobalPermissions } from "@lib/util"; import _ from "lodash"; // Collection names @@ -100,15 +101,16 @@ export class Admin { } } + const userPermissions = parseGlobalPermissions(user.permissions); const permissions: UserGlobalPermissions = { features: { - ai: user.permissions.features.ai, - api: user.permissions.features.api, - import: user.permissions.features.import, - scan: user.permissions.features.scan, + ai: userPermissions.features.ai, + api: userPermissions.features.api, + import: userPermissions.features.import, + scan: userPermissions.features.scan, }, workspaces: { - create: user.permissions.workspaces.create, + create: userPermissions.workspaces.create, }, }; @@ -162,7 +164,7 @@ export class Admin { return DEFAULT_GLOBAL_PERMISSIONS; } - return userResult.permissions; + return parseGlobalPermissions(userResult.permissions); }; static setUserGlobalPermissions = async ( @@ -181,7 +183,7 @@ export class Admin { const update: { $set: UserModel } = { $set: { ...user, - permissions, + permissions: JSON.stringify(permissions) as unknown as UserGlobalPermissions, }, }; @@ -324,6 +326,8 @@ export class Admin { }; } + const globalPermissions = parseGlobalPermissions(userResult.permissions); + // Check if User is Workspace owner or Collaborator if (workspaceResult.owner === _id) { // If owner, all permissions granted @@ -349,7 +353,7 @@ export class Admin { archive: true, }, }, - global: userResult.permissions, + global: globalPermissions, }; } else { const workspacePermissions = workspaceResult.collaborators.filter((collaborator: Collaborator) => { @@ -359,13 +363,13 @@ export class Admin { if (workspacePermissions.length !== 1) { return { workspace: DEFAULT_WORKSPACE_PERMISSIONS, // Replace with default permissions - global: userResult.permissions, + global: globalPermissions, }; } return { workspace: workspacePermissions[0].permissions, - global: userResult.permissions, + global: globalPermissions, }; } }; diff --git a/types/index.d.ts b/types/index.d.ts index 03fe2337..b2fccded 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -141,7 +141,7 @@ export type CollaboratorsProps = { currentUser: string; owner: string; collaborators: Collaborator[]; - setCollaborators: (value: React.SetStateAction) => void; + setCollaborators: (value: React.SetStateAction) => void; }; // "Linky" component props @@ -794,6 +794,7 @@ export type PermissionsDialogProps = { user: string; isGlobal: boolean; // Define if modifying "global" permissions or just for the Workspace workspace?: string; // Specify the Workspace if modifying Workspace permissions + editable?: boolean; // If `false`, show a read-only preview instead of editable toggles }; // Metrics From e5cab7e7339db692e7aa1bca2a9cb4c838b60e63 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 09:54:15 -0500 Subject: [PATCH 12/23] MARS-1190 Update test helpers --- .../integration/actions/dashboard.test.ts | 2 +- .../test/integration/actions/entity.test.ts | 2 +- .../test/integration/actions/project.test.ts | 2 +- .../test/integration/actions/template.test.ts | 2 +- client/test/integration/global.setup.ts | 2 +- client/test/integration/global.teardown.ts | 2 +- .../{helpers.ts => helpers/global.helpers.ts} | 78 ++++++--- .../helpers/permissions.helpers.ts | 162 ++++++++++++++++++ types/index.d.ts | 7 + 9 files changed, 230 insertions(+), 29 deletions(-) rename client/test/integration/{helpers.ts => helpers/global.helpers.ts} (85%) create mode 100644 client/test/integration/helpers/permissions.helpers.ts diff --git a/client/test/integration/actions/dashboard.test.ts b/client/test/integration/actions/dashboard.test.ts index 39164fef..44aad0eb 100644 --- a/client/test/integration/actions/dashboard.test.ts +++ b/client/test/integration/actions/dashboard.test.ts @@ -2,7 +2,7 @@ import test, { expect } from "@playwright/test"; // Test helper functions -import { createTestUser, createTestWorkspace, switchWorkspace } from "../helpers"; +import { createTestUser, createTestWorkspace, switchWorkspace } from "../helpers/global.helpers"; test.describe("Dashboard", () => { test.beforeEach(async ({ context, page }) => { diff --git a/client/test/integration/actions/entity.test.ts b/client/test/integration/actions/entity.test.ts index 03ab9a7e..9387ffb0 100644 --- a/client/test/integration/actions/entity.test.ts +++ b/client/test/integration/actions/entity.test.ts @@ -11,7 +11,7 @@ import { createTestUser, createTestWorkspace, switchWorkspace, -} from "../helpers"; +} from "../helpers/global.helpers"; test.describe("Entity", () => { test.describe("Edit", () => { diff --git a/client/test/integration/actions/project.test.ts b/client/test/integration/actions/project.test.ts index 99474cd0..ae2c19fc 100644 --- a/client/test/integration/actions/project.test.ts +++ b/client/test/integration/actions/project.test.ts @@ -13,7 +13,7 @@ import { createTestWorkspace, switchWorkspace, addEntityToProject, -} from "../helpers"; +} from "../helpers/global.helpers"; test.describe("Project", () => { test.describe("Edit: Details", () => { diff --git a/client/test/integration/actions/template.test.ts b/client/test/integration/actions/template.test.ts index bbc2de52..5a847f56 100644 --- a/client/test/integration/actions/template.test.ts +++ b/client/test/integration/actions/template.test.ts @@ -11,7 +11,7 @@ import { createTestWorkspace, createTestUser, switchWorkspace, -} from "../helpers"; +} from "../helpers/global.helpers"; test.describe("Template", () => { test.describe("Edit", () => { diff --git a/client/test/integration/global.setup.ts b/client/test/integration/global.setup.ts index 01cacf07..e49b1245 100644 --- a/client/test/integration/global.setup.ts +++ b/client/test/integration/global.setup.ts @@ -2,7 +2,7 @@ import { test as setup } from "@playwright/test"; // Test helpers -import { resetWorkspace } from "./helpers"; +import { resetWorkspace } from "./helpers/global.helpers"; setup("test setup", async ({ page }) => { await resetWorkspace(page); diff --git a/client/test/integration/global.teardown.ts b/client/test/integration/global.teardown.ts index 57ce0b42..0e1638a9 100644 --- a/client/test/integration/global.teardown.ts +++ b/client/test/integration/global.teardown.ts @@ -2,7 +2,7 @@ import { test as teardown } from "@playwright/test"; // Test helpers -import { resetWorkspace } from "./helpers"; +import { resetWorkspace } from "./helpers/global.helpers"; teardown("test teardown", async ({ page }) => { await resetWorkspace(page); diff --git a/client/test/integration/helpers.ts b/client/test/integration/helpers/global.helpers.ts similarity index 85% rename from client/test/integration/helpers.ts rename to client/test/integration/helpers/global.helpers.ts index 8b491cc5..297b5eef 100644 --- a/client/test/integration/helpers.ts +++ b/client/test/integration/helpers/global.helpers.ts @@ -5,19 +5,19 @@ import { BrowserContext, Locator, Page } from "@playwright/test"; import "dotenv/config"; // Server functions -import { connect, disconnect } from "../../../server/src/connectors/database"; -import { setupDatabase, teardownDatabase } from "../../../server/test/helpers"; -import { getAuth } from "../../../server/test/helpers"; +import { connect, disconnect } from "../../../../server/src/connectors/database"; +import { setupDatabase, teardownDatabase } from "../../../../server/test/helpers"; +import { getAuth } from "../../../../server/test/helpers"; // Models -import { Entities } from "../../../server/src/models/Entities"; -import { Projects } from "../../../server/src/models/Projects"; -import { Templates } from "../../../server/src/models/Templates"; -import { Workspaces } from "../../../server/src/models/Workspaces"; -import { User } from "../../../server/src/models/User"; +import { Entities } from "../../../../server/src/models/Entities"; +import { Projects } from "../../../../server/src/models/Projects"; +import { Templates } from "../../../../server/src/models/Templates"; +import { Workspaces } from "../../../../server/src/models/Workspaces"; +import { User } from "../../../../server/src/models/User"; // Custom types -import { IAttribute, IEntity, IProject, IWorkspace, ResponseData } from "../../../types"; +import { Collaborator, IAttribute, IEntity, IProject, IWorkspace, ResponseData } from "../../../../types"; // Utility functions import dayjs from "dayjs"; @@ -27,11 +27,18 @@ import dayjs from "dayjs"; * @param {string} name The name of the Entity to create * @param {string} owner The owner of the Entity * @param {string} workspace The _id of the Workspace to contain the Entity + * @param {boolean} archived Whether the Entity should start archived + * @return {Promise} Created Entity identifier */ -export const createTestEntity = async (name: string, owner: string, workspace: string): Promise => { +export const createTestEntity = async ( + name: string, + owner: string, + workspace: string, + archived = false, +): Promise => { await connect(); const entity: IEntity = { - archived: false, + archived: archived, name: name, created: dayjs("2023-10-01").toISOString(), owner: owner, @@ -65,6 +72,7 @@ export const createTestEntity = async (name: string, owner: string, workspace: s // Add the Entity to the Workspace await Workspaces.addEntity(workspace, result.data); await disconnect(); + return result.data; }; /** @@ -72,16 +80,22 @@ export const createTestEntity = async (name: string, owner: string, workspace: s * @param {string} name The name of the Project to create * @param {string} owner The owner of the Project * @param {string} workspace The _id of the Workspace to contain the Project + * @param {boolean} archived Whether the Project should start archived + * @return {Promise} Created Project identifier */ -export const createTestProject = async (name: string, owner: string, workspace: string): Promise => { +export const createTestProject = async ( + name: string, + owner: string, + workspace: string, + archived = false, +): Promise => { await connect(); const project: IProject = { name: name, description: "Test Project", owner: owner, created: dayjs("2023-10-01").toISOString(), - archived: false, - collaborators: [], + archived: archived, entities: [], history: [], }; @@ -94,6 +108,7 @@ export const createTestProject = async (name: string, owner: string, workspace: // Add the Project to the Workspace await Workspaces.addProject(workspace, result.data); await disconnect(); + return result.data; }; /** @@ -101,11 +116,18 @@ export const createTestProject = async (name: string, owner: string, workspace: * @param {string} name The name of the Template to create * @param {string} owner The owner of the Template * @param {string} workspace The _id of the Workspace to contain the Template + * @param {boolean} archived Whether the Template should start archived + * @return {Promise} Created Template identifier */ -export const createTestTemplate = async (name: string, owner: string, workspace: string): Promise => { +export const createTestTemplate = async ( + name: string, + owner: string, + workspace: string, + archived = false, +): Promise => { await connect(); const template: IAttribute = { - archived: false, + archived: archived, name: name, description: "Test Attribute", owner: owner, @@ -123,21 +145,27 @@ export const createTestTemplate = async (name: string, owner: string, workspace: // Add the Template to the Workspace await Workspaces.addTemplate(workspace, result.data); await disconnect(); + return result.data; }; /** * Create a new Workspace for a specific test or test suite * @param {string} name Workspace name * @param {string} owner Workspace owner + * @param {Collaborator[]} collaborators Collaborators to seed the Workspace with, used for permissions tests * @return {Promise} Created Workspace identifier */ -export const createTestWorkspace = async (name: string, owner: string): Promise => { +export const createTestWorkspace = async ( + name: string, + owner: string, + collaborators: Collaborator[] = [], +): Promise => { await connect(); const workspace: IWorkspace = { name: name, description: "Test Workspace", owner: owner, - collaborators: [], + collaborators: collaborators, public: false, entities: [], projects: [], @@ -156,11 +184,15 @@ export const createTestWorkspace = async (name: string, owner: string): Promise< }; /** - * Create a new user account using the default test user information - * @param page + * Create a new user account, defaulting to the standard test user credentials + * @param context Browser context to receive the session cookies, use a separate context per User + * @param options Override the default email/name, needed when a test requires more than one User * @return {string} `userId` of the created test user */ -export const createTestUser = async (context: BrowserContext): Promise => { +export const createTestUser = async ( + context: BrowserContext, + options?: { email?: string; name?: string }, +): Promise => { await connect(); // Setup User @@ -169,8 +201,8 @@ export const createTestUser = async (context: BrowserContext): Promise = const testUtils = ctx.test; const user = testUtils.createUser({ - email: process.env.TEST_USER_EMAIL, - name: "Test User", + email: options?.email ?? process.env.TEST_USER_EMAIL, + name: options?.name ?? "Test User", completedProfile: true, }); await testUtils.saveUser(user); diff --git a/client/test/integration/helpers/permissions.helpers.ts b/client/test/integration/helpers/permissions.helpers.ts new file mode 100644 index 00000000..fc339dae --- /dev/null +++ b/client/test/integration/helpers/permissions.helpers.ts @@ -0,0 +1,162 @@ +// Playwright imports +import { BrowserContext, Locator, Page, expect, test as base } from "@playwright/test"; + +// Test helper functions +import { createTestUser, createTestWorkspace, getUniqueName } from "./global.helpers"; + +// Server models, used to seed a Workspace Collaborator directly +import { DEFAULT_WORKSPACE_PERMISSIONS } from "../../../../server/src/models/Admin"; + +// Custom types +import { ClientPath } from "../../../../types"; + +/** + * Extend the `test` context to share nothing with the Workspace Owner's context + */ +export const test = base.extend<{ collaboratorPage: Page }>({ + collaboratorPage: async ({ browser }, use) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); + +/** + * Create an Owner and a Collaborator in separate browser sessions, sharing a fresh Workspace + * @param {BrowserContext} ownerContext Context for the Workspace owner + * @param {Page} collaboratorPage Page instance associated with the Collaborator + * @param {string} baseWorkspaceName Initial Workspace name to base new Workspace name from + * @return {{ owner: string; collaborator: string; workspaceId: string; }} + */ +export const setupDefaultPermissions = async ( + ownerContext: BrowserContext, + collaboratorPage: Page, + baseWorkspaceName: string, +): Promise<{ + owner: string; + collaborator: string; + workspaceId: string; +}> => { + const owner = await createTestUser(ownerContext); + + const collaboratorEmail = `${getUniqueName("collaborator").replace(/\s+/g, "-").toLowerCase()}@test.com`; + const collaborator = await createTestUser(collaboratorPage.context(), { + email: collaboratorEmail, + name: "Test Collaborator", + }); + + const workspaceName = getUniqueName(baseWorkspaceName); + const workspaceId = await createTestWorkspace(workspaceName, owner, [ + { _id: collaborator, permissions: DEFAULT_WORKSPACE_PERMISSIONS }, + ]); + + // A freshly created User has no other Workspace to be active + await collaboratorPage.goto("/"); + await collaboratorPage.waitForLoadState("networkidle"); + + return { owner, collaborator, workspaceId }; +}; + +/** + * Open a Workspace's management page directly + * @param {Page} page Current test Page + * @param {string} workspaceId Identifier of the Workspace to open the management page + */ +export const openManageWorkspace = async (page: Page, workspaceId: string): Promise => { + await page.goto(`/workspaces/${workspaceId}`); + await page.waitForLoadState("networkidle"); +}; + +/** + * Toggle one of the Collaborator's Workspace permission switches by its label + * @param {Page} page Current test Page + * @param {string} switchLabel Text label of the `Switch` component to toggle + */ +export const toggleCollaboratorPermission = async (page: Page, switchLabel: string): Promise => { + await page.getByRole("button", { name: "Manage permissions" }).click(); + await page.waitForLoadState("networkidle"); + await page.getByText(switchLabel, { exact: true }).click(); + await page.getByRole("button", { name: "Done" }).click(); + await expect(page.getByText("Updated User Workspace permissions")).toBeVisible(); +}; + +/** + * Access point state gated by an enabled or disabled form control + * @param {string} name Path name + * @param {string} path Exact path + * @param {(page: Page) => Locator} locator Playwright `Locator` to establish gate form control + * @return {ClientPath} + */ +export const clientPathDisabled = (name: string, path: string, locator: (page: Page) => Locator): ClientPath => { + return { + name: name, + verify: async (page, granted) => { + await page.goto(path); + await page.waitForLoadState("networkidle"); + if (granted) { + await expect(locator(page)).toBeEnabled(); + } else { + await expect(locator(page)).toBeDisabled(); + } + }, + }; +}; + +/** + * Access point state gated by an visible form control + * @param {string} name Path name + * @param {string} path Exact path + * @param {(page: Page) => Locator} locator Playwright `Locator` to establish gate form control + * @return {ClientPath} + */ +export const clientPathVisible = (name: string, path: string, locator: (page: Page) => Locator): ClientPath => { + return { + name: name, + verify: async (page, granted) => { + await page.goto(path); + await page.waitForLoadState("networkidle"); + if (granted) { + await expect(locator(page)).toBeVisible(); + } else { + await expect(locator(page)).toBeHidden(); + } + }, + }; +}; + +/** + * The "Archive" item inside a view page's "Actions" menu, shared shape across Entities, Projects, and Templates + * @param {string} name Path name + * @param {string} path Exact path + * @return {ClientPath} + */ +export const clientPathArchive = (name: string, path: string): ClientPath => { + return { + name: name, + verify: async (page, granted) => { + await page.goto(path); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Actions" }).click(); + const archiveItem = page.getByRole("menuitem", { name: "Archive" }); + if (granted) { + await expect(archiveItem).toBeEnabled(); + } else { + await expect(archiveItem).toBeDisabled(); + } + await page.keyboard.press("Escape"); + }, + }; +}; + +/** + * Verify a set of `ClientPath`s and evaluate permissions status + * @param {Page} page Current test Page + * @param {ClientPath[]} paths Collection of `ClientPath`s to verify + * @param {boolean} granted State of whether access should be granted or not + */ +export const verifyClientPaths = async (page: Page, paths: ClientPath[], granted: boolean): Promise => { + for (const path of paths) { + await path.verify(page, granted); + } +}; diff --git a/types/index.d.ts b/types/index.d.ts index b2fccded..b45b4d31 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -839,3 +839,10 @@ export type AdminUser = { banned: boolean; lastLogin: string; }; + +// Types to assist with test frameworks +// A path on the client where a Workspace permission is enforced +export type ClientPath = { + name: string; + verify: (page: Page, granted: boolean) => Promise; +}; From b1cd410e79c8fa5f792e4071c7ec1aa852e2d811 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 09:54:49 -0500 Subject: [PATCH 13/23] MARS-1190 Add server `admin` tests * Update types for server tests --- server/test/models/Admin.test.ts | 217 ++++++++++++++++++++++++++++ server/test/models/Entities.test.ts | 2 - server/test/models/Projects.test.ts | 3 - 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 server/test/models/Admin.test.ts diff --git a/server/test/models/Admin.test.ts b/server/test/models/Admin.test.ts new file mode 100644 index 00000000..b411ee58 --- /dev/null +++ b/server/test/models/Admin.test.ts @@ -0,0 +1,217 @@ +// .env configuration +import "dotenv/config"; + +// Jest imports +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; + +// Models under test +import { Admin, DEFAULT_GLOBAL_PERMISSIONS, DEFAULT_WORKSPACE_PERMISSIONS } from "@models/Admin"; +import { User } from "@models/User"; +import { Workspaces } from "@models/Workspaces"; + +// Types +import { ResponseData, UserModel, UserWorkspacePermissions } from "@types"; + +// Database connectivity +import { connect, disconnect } from "@connectors/database"; +import { clearDatabase } from "../helpers"; + +import dayjs from "dayjs"; +import _ from "lodash"; + +// Variables +const OWNER_ID = "owner-user"; +const COLLABORATOR_ID = "collaborator-user"; +const OUTSIDER_ID = "outsider-user"; + +// Minimal UserModel instance, global permissions all disabled by default +const buildUser = (_id: string): UserModel => ({ + _id, + firstName: "Test", + lastName: "User", + name: "Test User", + affiliation: "", + email: `${_id}@test.com`, + emailVerified: true, + image: "", + createdAt: dayjs(Date.now()).toISOString(), + updatedAt: dayjs(Date.now()).toISOString(), + lastLogin: dayjs(Date.now()).toISOString(), + api_keys: JSON.stringify([]), + role: "user", + banned: false, + permissions: DEFAULT_GLOBAL_PERMISSIONS, + account_orcid: "", +}); + +const createWorkspace = async (collaboratorPermissions: UserWorkspacePermissions): Promise => { + const result: ResponseData = await Workspaces.create({ + name: "Test Workspace", + owner: OWNER_ID, + public: false, + description: "Workspace for permission tests", + collaborators: [{ _id: COLLABORATOR_ID, permissions: collaboratorPermissions }], + entities: [], + projects: [], + templates: [], + activity: [], + }); + return result.data; +}; + +describe("Admin model permissions", () => { + beforeEach(async () => { + await connect(); + await clearDatabase(); + }); + + afterEach(async () => { + await clearDatabase(); + await disconnect(); + }); + + describe("Global permissions", () => { + it("returns default permissions for a User that doesn't exist", async () => { + const permissions = await Admin.getUserGlobalPermissions("nonexistent"); + expect(permissions).toEqual(DEFAULT_GLOBAL_PERMISSIONS); + }); + + it("enables a Global permission", async () => { + await User.create(buildUser(OWNER_ID)); + + const updated = _.cloneDeep(DEFAULT_GLOBAL_PERMISSIONS); + updated.features.ai = true; + const result = await Admin.setUserGlobalPermissions(OWNER_ID, updated); + expect(result.success).toBeTruthy(); + + const permissions = await Admin.getUserGlobalPermissions(OWNER_ID); + expect(permissions.features.ai).toBeTruthy(); + expect(permissions.features.scan).toBeFalsy(); + }); + + it("disables a previously enabled Global permission", async () => { + await User.create(buildUser(OWNER_ID)); + + const enabled = _.cloneDeep(DEFAULT_GLOBAL_PERMISSIONS); + enabled.workspaces.create = true; + await Admin.setUserGlobalPermissions(OWNER_ID, enabled); + + const disabled = _.cloneDeep(enabled); + disabled.workspaces.create = false; + await Admin.setUserGlobalPermissions(OWNER_ID, disabled); + + const permissions = await Admin.getUserGlobalPermissions(OWNER_ID); + expect(permissions.workspaces.create).toBeFalsy(); + }); + + it("fails to update permissions for a User that doesn't exist", async () => { + const result = await Admin.setUserGlobalPermissions("nonexistent", DEFAULT_GLOBAL_PERMISSIONS); + expect(result.success).toBeFalsy(); + }); + }); + + describe("Workspace-scoped permissions", () => { + it("returns default permissions when the Workspace doesn't exist", async () => { + const permissions = await Admin.getUserWorkspacePermissions(COLLABORATOR_ID, "nonexistent"); + expect(permissions).toEqual(DEFAULT_WORKSPACE_PERMISSIONS); + }); + + it("grants the owner full permissions even without a Collaborator entry", async () => { + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + + const permissions = await Admin.getUserWorkspacePermissions(OWNER_ID, workspaceId); + expect(permissions.entities.create).toBeTruthy(); + expect(permissions.projects.archive).toBeTruthy(); + expect(permissions.administration.invite).toBeTruthy(); + }); + + it("returns a Collaborator's stored permissions rather than the defaults", async () => { + const collaboratorPermissions = _.cloneDeep(DEFAULT_WORKSPACE_PERMISSIONS); + collaboratorPermissions.entities.create = true; + const workspaceId = await createWorkspace(collaboratorPermissions); + + const permissions = await Admin.getUserWorkspacePermissions(COLLABORATOR_ID, workspaceId); + expect(permissions.entities.create).toBeTruthy(); + expect(permissions.entities.edit).toBeFalsy(); + }); + + it("enables a specific permission for a Collaborator without touching the others", async () => { + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + + const result = await Admin.setUserWorkspacePermissions(COLLABORATOR_ID, workspaceId, { + projects: { create: true, edit: false, archive: false }, + }); + expect(result.success).toBeTruthy(); + + const permissions = await Admin.getUserWorkspacePermissions(COLLABORATOR_ID, workspaceId); + expect(permissions.projects.create).toBeTruthy(); + expect(permissions.entities.create).toBeFalsy(); + }); + + it("disables a previously enabled permission for a Collaborator", async () => { + const enabled = _.cloneDeep(DEFAULT_WORKSPACE_PERMISSIONS); + enabled.templates.archive = true; + const workspaceId = await createWorkspace(enabled); + + await Admin.setUserWorkspacePermissions(COLLABORATOR_ID, workspaceId, { + templates: { create: false, edit: false, archive: false }, + }); + + const permissions = await Admin.getUserWorkspacePermissions(COLLABORATOR_ID, workspaceId); + expect(permissions.templates.archive).toBeFalsy(); + }); + + it("fails to update permissions for a User who isn't a Collaborator", async () => { + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + + const result = await Admin.setUserWorkspacePermissions(OUTSIDER_ID, workspaceId, { + entities: { create: true, edit: false, archive: false }, + }); + expect(result.success).toBeFalsy(); + }); + }); + + describe("Collated permissions", () => { + it("combines Global and Workspace permissions for a Collaborator", async () => { + await User.create(buildUser(COLLABORATOR_ID)); + const global = _.cloneDeep(DEFAULT_GLOBAL_PERMISSIONS); + global.features.api = true; + await Admin.setUserGlobalPermissions(COLLABORATOR_ID, global); + + const workspacePermissions = _.cloneDeep(DEFAULT_WORKSPACE_PERMISSIONS); + workspacePermissions.entities.edit = true; + const workspaceId = await createWorkspace(workspacePermissions); + + const collated = await Admin.getUserCollatedPermissions(COLLABORATOR_ID, workspaceId); + expect(collated.global.features.api).toBeTruthy(); + expect(collated.workspace.entities.edit).toBeTruthy(); + expect(collated.workspace.entities.create).toBeFalsy(); + }); + + it("grants the owner full Workspace permissions regardless of stored Global permissions", async () => { + await User.create(buildUser(OWNER_ID)); + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + + const collated = await Admin.getUserCollatedPermissions(OWNER_ID, workspaceId); + expect(collated.workspace.entities.create).toBeTruthy(); + expect(collated.workspace.administration.edit).toBeTruthy(); + }); + }); + + describe("Workspace access", () => { + it("grants access to the owner", async () => { + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + expect(await Workspaces.checkAccess(OWNER_ID, workspaceId)).toBeTruthy(); + }); + + it("grants access to a Collaborator", async () => { + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + expect(await Workspaces.checkAccess(COLLABORATOR_ID, workspaceId)).toBeTruthy(); + }); + + it("denies access to a User who is neither the owner nor a Collaborator", async () => { + const workspaceId = await createWorkspace(DEFAULT_WORKSPACE_PERMISSIONS); + expect(await Workspaces.checkAccess(OUTSIDER_ID, workspaceId)).toBeFalsy(); + }); + }); +}); diff --git a/server/test/models/Entities.test.ts b/server/test/models/Entities.test.ts index 4cc9c243..5c67bae8 100644 --- a/server/test/models/Entities.test.ts +++ b/server/test/models/Entities.test.ts @@ -242,7 +242,6 @@ describe("Entity model", () => { owner: "henry.burgess@wustl.edu", description: "Test Project", entities: [], - collaborators: [], history: [], }); @@ -289,7 +288,6 @@ describe("Entity model", () => { owner: "henry.burgess@wustl.edu", description: "Test Project", entities: [], - collaborators: [], history: [], }); diff --git a/server/test/models/Projects.test.ts b/server/test/models/Projects.test.ts index 39bf7c4a..8088bdb6 100644 --- a/server/test/models/Projects.test.ts +++ b/server/test/models/Projects.test.ts @@ -43,7 +43,6 @@ describe("Project model", () => { owner: "henry.burgess@wustl.edu", description: "Test Project", entities: [], - collaborators: [], history: [], }); expect(result.success).toBeTruthy(); @@ -63,7 +62,6 @@ describe("Project model", () => { owner: "henry.burgess@wustl.edu", description: `Test Project ${i}`, entities: [], - collaborators: [], history: [], }); } @@ -89,7 +87,6 @@ describe("Project model", () => { owner: "henry.burgess@wustl.edu", description: "Test Project", entities: [], - collaborators: [], history: [], }); From ed1c5821c2340695ac1d570b63e6e47de664ceb6 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 09:55:01 -0500 Subject: [PATCH 14/23] MARS-1190 Add new `permissions` client test suite --- .../integration/actions/permissions.test.ts | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 client/test/integration/actions/permissions.test.ts diff --git a/client/test/integration/actions/permissions.test.ts b/client/test/integration/actions/permissions.test.ts new file mode 100644 index 00000000..54280da4 --- /dev/null +++ b/client/test/integration/actions/permissions.test.ts @@ -0,0 +1,211 @@ +// Test helper functions +import { createTestEntity, createTestProject, createTestTemplate } from "../helpers/global.helpers"; +import { + clientPathArchive, + clientPathDisabled, + clientPathVisible, + openManageWorkspace, + setupDefaultPermissions, + test, + toggleCollaboratorPermission, + verifyClientPaths, +} from "../helpers/permissions.helpers"; + +// Custom types +import { ClientPath } from "../../../../types"; + +// Each test drives two browser sessions through several page loads, and the shared +// database only gets cleared once for the whole suite, so give these more room than the default +test.describe.configure({ timeout: 60_000 }); + +test.describe("Workspace Administration permissions", () => { + test("Edit Workspace Details", async ({ context, page, collaboratorPage }) => { + const { workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Admin-Edit"); + const workspacePath = `/workspaces/${workspaceId}`; + + const clientPaths: ClientPath[] = [ + clientPathDisabled("Workspace name field", workspacePath, (p) => p.locator("#dialogWorkspaceName")), + clientPathVisible("Workspace save button", workspacePath, (p) => p.locator("#dialogWorkspaceCreateButton")), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Edit Workspace Details"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Invite Collaborators", async ({ context, page, collaboratorPage }) => { + const { workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Admin-Invite"); + const workspacePath = `/workspaces/${workspaceId}`; + + const clientPaths: ClientPath[] = [ + clientPathVisible("Invite Collaborator field", workspacePath, (p) => p.getByPlaceholder("Email")), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Invite Collaborators"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); +}); + +test.describe("Entity permissions", () => { + test("Create Entities", async ({ context, page, collaboratorPage }) => { + const { workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Entity-Create"); + + const clientPaths: ClientPath[] = [ + clientPathDisabled("Entities list button", "/entities", (p) => + p.getByRole("button", { name: "Create Entity", exact: true }), + ), + clientPathDisabled("Create hub button", "/create", (p) => p.locator("#createEntityButton")), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Create Entities"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Edit Entities", async ({ context, page, collaboratorPage }) => { + const { owner, workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Entity-Edit"); + const entityId = await createTestEntity("Permission Test Entity", owner, workspaceId); + + const clientPaths: ClientPath[] = [ + clientPathDisabled("Entity view Edit button", `/entities/${entityId}`, (p) => + p.getByRole("button", { name: "Edit", exact: true }), + ), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Edit Entities"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Archive Entities", async ({ context, page, collaboratorPage }) => { + const { owner, workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Entity-Archive"); + const entityId = await createTestEntity("Permission Test Entity", owner, workspaceId); + await createTestEntity("Permission Test Archived Entity", owner, workspaceId, true); + const workspacePath = `/workspaces/${workspaceId}`; + + const clientPaths: ClientPath[] = [ + clientPathArchive("Entity view Archive menu item", `/entities/${entityId}`), + clientPathDisabled("Workspace archived Entities restore button", workspacePath, (p) => + p.getByRole("button", { name: "Restore", exact: true }), + ), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Archive Entities"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); +}); + +test.describe("Project permissions", () => { + test("Create Projects", async ({ context, page, collaboratorPage }) => { + const { workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Project-Create"); + + // Note: the direct "/create/project" route guard checks `entities.create` rather than + // `projects.create` (an existing app inconsistency), so it's left out of this list + const clientPaths: ClientPath[] = [ + clientPathDisabled("Projects list button", "/projects", (p) => + p.getByRole("button", { name: "Create Project", exact: true }), + ), + clientPathDisabled("Create hub button", "/create", (p) => p.locator("#createProjectButton")), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Create Projects"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Edit Projects", async ({ context, page, collaboratorPage }) => { + const { owner, workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Project-Edit"); + const projectId = await createTestProject("Permission Test Project", owner, workspaceId); + + const clientPaths: ClientPath[] = [ + clientPathDisabled("Project view Edit button", `/projects/${projectId}`, (p) => + p.getByRole("button", { name: "Edit", exact: true }), + ), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Edit Projects"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Archive Projects", async ({ context, page, collaboratorPage }) => { + const { owner, workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Project-Archive"); + const projectId = await createTestProject("Permission Test Project", owner, workspaceId); + await createTestProject("Permission Test Archived Project", owner, workspaceId, true); + const workspacePath = `/workspaces/${workspaceId}`; + + const clientPaths: ClientPath[] = [ + clientPathArchive("Project view Archive menu item", `/projects/${projectId}`), + clientPathDisabled("Workspace archived Projects restore button", workspacePath, (p) => + p.getByRole("button", { name: "Restore Project", exact: true }), + ), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Archive Projects"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); +}); + +test.describe("Template permissions", () => { + test("Create Templates", async ({ context, page, collaboratorPage }) => { + const { workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Template-Create"); + + const clientPaths: ClientPath[] = [ + clientPathDisabled("Templates list button", "/templates", (p) => + p.getByRole("button", { name: "Create Template", exact: true }), + ), + clientPathDisabled("Create hub button", "/create", (p) => p.locator("#createTemplateButton")), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Create Templates"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Edit Templates", async ({ context, page, collaboratorPage }) => { + const { owner, workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Template-Edit"); + const templateId = await createTestTemplate("Permission Test Template", owner, workspaceId); + + const clientPaths: ClientPath[] = [ + clientPathDisabled("Template view Edit button", `/templates/${templateId}`, (p) => + p.getByRole("button", { name: "Edit", exact: true }), + ), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Edit Templates"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); + + test("Archive Templates", async ({ context, page, collaboratorPage }) => { + const { owner, workspaceId } = await setupDefaultPermissions(context, collaboratorPage, "Perm-Template-Archive"); + const templateId = await createTestTemplate("Permission Test Template", owner, workspaceId); + await createTestTemplate("Permission Test Archived Template", owner, workspaceId, true); + const workspacePath = `/workspaces/${workspaceId}`; + + const clientPaths: ClientPath[] = [ + clientPathArchive("Template view Archive menu item", `/templates/${templateId}`), + clientPathDisabled("Workspace archived Templates restore button", workspacePath, (p) => + p.getByRole("button", { name: "Restore Template", exact: true }), + ), + ]; + + await verifyClientPaths(collaboratorPage, clientPaths, false); + await openManageWorkspace(page, workspaceId); + await toggleCollaboratorPermission(page, "Archive Templates"); + await verifyClientPaths(collaboratorPage, clientPaths, true); + }); +}); From c4b6d1917e8fc343a199c2fdab4e2796a0a900af Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 10:09:14 -0500 Subject: [PATCH 15/23] MARS-1190 Poll permissions updates --- client/src/hooks/usePermissions/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/hooks/usePermissions/index.tsx b/client/src/hooks/usePermissions/index.tsx index 2466a2e5..6c126c0e 100644 --- a/client/src/hooks/usePermissions/index.tsx +++ b/client/src/hooks/usePermissions/index.tsx @@ -59,6 +59,7 @@ const PermissionsContext = createContext({} as Permissi export const PermissionsProvider = (props: { children: React.JSX.Element }) => { const { data } = useQuery<{ userCollatedPermissions: UserCollatedPermissions }>(GET_USER_PERMISSIONS, { fetchPolicy: "network-only", + pollInterval: 1000, // Poll every seconds to pick up permission changes }); const value = useMemo( From 684facdcfcfb7730890ca5e6a817095262448fa3 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 10:24:09 -0500 Subject: [PATCH 16/23] MARS-1190 Defer updating permissions to saving --- client/src/components/Collaborators/index.tsx | 14 +- .../components/PermissionsDialog/index.tsx | 174 +++++------------- client/src/lib/util.ts | 31 ++++ .../helpers/permissions.helpers.ts | 6 +- types/index.d.ts | 4 +- 5 files changed, 94 insertions(+), 135 deletions(-) diff --git a/client/src/components/Collaborators/index.tsx b/client/src/components/Collaborators/index.tsx index c020d0b0..badf18bc 100644 --- a/client/src/components/Collaborators/index.tsx +++ b/client/src/components/Collaborators/index.tsx @@ -15,7 +15,13 @@ import { gql } from "@apollo/client"; import { useLazyQuery, useQuery } from "@apollo/client/react"; // Utility functions -import { isValidEmail, ignoreAbort, isCollaborator } from "@lib/util"; +import { + getCollaboratorPermissions, + ignoreAbort, + isCollaborator, + isValidEmail, + setCollaboratorPermissions, +} from "@lib/util"; // Variables import { DEFAULT_WORKSPACE_PERMISSIONS, GLOBAL_STYLES } from "@variables"; @@ -297,6 +303,12 @@ const Collaborators = (props: CollaboratorsProps) => { user={permissionsDialogUser} isGlobal={false} editable={isOwner} + workspacePermissions={getCollaboratorPermissions(permissionsDialogUser, props.collaborators)} + onUpdateWorkspacePermissions={(permissions) => + props.setCollaborators((collaborators) => + setCollaboratorPermissions(permissionsDialogUser, permissions, collaborators), + ) + } /> )} diff --git a/client/src/components/PermissionsDialog/index.tsx b/client/src/components/PermissionsDialog/index.tsx index 9abf6ee7..87892f55 100644 --- a/client/src/components/PermissionsDialog/index.tsx +++ b/client/src/components/PermissionsDialog/index.tsx @@ -103,77 +103,30 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { } }; - // If `isGlobal` is `false`, get the Workspace permissions of the User we are modifying - const GET_USER_WORKSPACE_PERMISSIONS = gql` - query GetUserWorkspacePermissions($_id: String, $workspace: String) { - userWorkspacePermissions(_id: $_id, workspace: $workspace) { - administration { - edit - invite - } - entities { - create - edit - archive - } - projects { - create - edit - archive - } - templates { - create - edit - archive - } - } - } - `; - - const [getUserWorkspacePermissions] = useLazyQuery<{ userWorkspacePermissions: UserWorkspacePermissions }>( - GET_USER_WORKSPACE_PERMISSIONS, - { - fetchPolicy: "network-only", - }, - ); - - const refreshUserWorkspacePermissionsState = async (_id: string) => { - const result = await getUserWorkspacePermissions({ - variables: { - _id: _id, - // Workspace ID passed through request `Context` - }, - }); - - if (result.data) { - setWorkspaceEdit(result.data.userWorkspacePermissions.administration.edit); - setWorkspaceInvite(result.data.userWorkspacePermissions.administration.invite); - setEntitiesCreate(result.data.userWorkspacePermissions.entities.create); - setEntitiesEdit(result.data.userWorkspacePermissions.entities.edit); - setEntitiesArchive(result.data.userWorkspacePermissions.entities.archive); - setProjectsCreate(result.data.userWorkspacePermissions.projects.create); - setProjectsEdit(result.data.userWorkspacePermissions.projects.edit); - setProjectsArchive(result.data.userWorkspacePermissions.projects.archive); - setTemplatesCreate(result.data.userWorkspacePermissions.templates.create); - setTemplatesEdit(result.data.userWorkspacePermissions.templates.edit); - setTemplatesArchive(result.data.userWorkspacePermissions.templates.archive); - } else { - toaster.create({ - title: "Could not retrieve User Workspace permissions", - type: "error", - duration: 2000, - closable: true, - }); - } + // If `isGlobal` is `false`, seed the toggles from the collaborator's local (unsaved) permissions + const applyLocalWorkspacePermissions = (permissions: UserWorkspacePermissions) => { + setWorkspaceEdit(permissions.administration.edit); + setWorkspaceInvite(permissions.administration.invite); + setEntitiesCreate(permissions.entities.create); + setEntitiesEdit(permissions.entities.edit); + setEntitiesArchive(permissions.entities.archive); + setProjectsCreate(permissions.projects.create); + setProjectsEdit(permissions.projects.edit); + setProjectsArchive(permissions.projects.archive); + setTemplatesCreate(permissions.templates.create); + setTemplatesEdit(permissions.templates.edit); + setTemplatesArchive(permissions.templates.archive); }; useEffect(() => { + if (!props.open) return; + if (props.isGlobal) { refreshUserGlobalPermissionsState(props.user); - } else { - refreshUserWorkspacePermissionsState(props.user); + } else if (props.workspacePermissions) { + applyLocalWorkspacePermissions(props.workspacePermissions); } - }, [props.user]); + }, [props.user, props.open]); // Mutation to update User global permissions const SET_USER_GLOBAL_PERMISSIONS = gql` @@ -189,26 +142,6 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { setUserGlobalPermissions: IResponseMessage; }>(SET_USER_GLOBAL_PERMISSIONS); - // Mutation to update User Workspace permissions - const SET_USER_WORKSPACE_PERMISSIONS = gql` - mutation SetUserWorkspacePermissions( - $_id: String - $workspace: String - $permissions: UserWorkspacePermissionsInput - ) { - setUserWorkspacePermissions(_id: $_id, workspace: $workspace, permissions: $permissions) { - success - message - } - } - `; - const [ - setUserWorkspacePermissions, - { loading: userSetWorkspacePermissionsLoading, error: userSetWorkspacePermissionsError }, - ] = useMutation<{ - setUserWorkspacePermissions: IResponseMessage; - }>(SET_USER_WORKSPACE_PERMISSIONS); - /** * Utility function to execute GraphQL manipulations updating the User permissions */ @@ -254,55 +187,32 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { props.setOpen(false); } } else { - // Execute GraphQL mutation - const result = await setUserWorkspacePermissions({ - fetchPolicy: "network-only", - variables: { - _id: props.user, - permissions: { - administration: { - edit: workspaceEdit, - invite: workspaceInvite, - }, - entities: { - create: entitiesCreate, - edit: entitiesEdit, - archive: entitiesArchive, - }, - projects: { - create: projectsCreate, - edit: projectsEdit, - archive: projectsArchive, - }, - templates: { - create: templatesCreate, - edit: templatesEdit, - archive: templatesArchive, - }, - }, + // Update the collaborator's permissions in local state only, the Workspace `Save` + // action is responsible for persisting them to the server + props.onUpdateWorkspacePermissions?.({ + administration: { + edit: workspaceEdit, + invite: workspaceInvite, + }, + entities: { + create: entitiesCreate, + edit: entitiesEdit, + archive: entitiesArchive, + }, + projects: { + create: projectsCreate, + edit: projectsEdit, + archive: projectsArchive, + }, + templates: { + create: templatesCreate, + edit: templatesEdit, + archive: templatesArchive, }, }); - if (userSetWorkspacePermissionsError) { - toaster.create({ - title: "Could not update User Workspace permissions", - type: "error", - duration: 2000, - closable: true, - }); - } - - if (result.data && result.data.setUserWorkspacePermissions.success) { - toaster.create({ - title: "Updated User Workspace permissions", - type: "success", - duration: 2000, - closable: true, - }); - - // Close the dialog - props.setOpen(false); - } + // Close the dialog + props.setOpen(false); } }; @@ -808,7 +718,7 @@ const PermissionsDialog = (props: PermissionsDialogProps) => { colorPalette={"green"} size={"xs"} rounded={"md"} - loading={editable && (userSetGlobalPermissionsLoading || userSetWorkspacePermissionsLoading)} + loading={editable && props.isGlobal && userSetGlobalPermissionsLoading} onClick={() => { if (editable) { // Apply updated permissions diff --git a/client/src/lib/util.ts b/client/src/lib/util.ts index 600ab8ca..4661f53f 100644 --- a/client/src/lib/util.ts +++ b/client/src/lib/util.ts @@ -12,6 +12,7 @@ import { SearchAttributeValue, SearchQuery, UserModel, + UserWorkspacePermissions, } from "@types"; // Utility functions @@ -91,6 +92,36 @@ export const isCollaborator = (_id: string, collaborators: Collaborator[]): bool return false; }; +/** + * Get a specific Collaborator's Workspace permissions from a collection + * @param {string} _id Identifier of the Collaborator to locate + * @param {Collaborator[]} collaborators Collection of `Collaborator` instances + * @return {UserWorkspacePermissions | undefined} Permissions if the Collaborator is present + */ +export const getCollaboratorPermissions = ( + _id: string, + collaborators: Collaborator[], +): UserWorkspacePermissions | undefined => { + return collaborators.find((collaborator) => collaborator._id === _id)?.permissions; +}; + +/** + * Replace a specific Collaborator's Workspace permissions within a collection + * @param {string} _id Identifier of the Collaborator to update + * @param {UserWorkspacePermissions} permissions Updated permissions to apply + * @param {Collaborator[]} collaborators Collection of `Collaborator` instances + * @return {Collaborator[]} New collection with the matching Collaborator updated + */ +export const setCollaboratorPermissions = ( + _id: string, + permissions: UserWorkspacePermissions, + collaborators: Collaborator[], +): Collaborator[] => { + return collaborators.map((collaborator) => + collaborator._id === _id ? { ...collaborator, permissions } : collaborator, + ); +}; + /** * Check if an ORCID is a valid format * @param {string} orcid the ORCID to check diff --git a/client/test/integration/helpers/permissions.helpers.ts b/client/test/integration/helpers/permissions.helpers.ts index fc339dae..0ca62ab6 100644 --- a/client/test/integration/helpers/permissions.helpers.ts +++ b/client/test/integration/helpers/permissions.helpers.ts @@ -78,7 +78,11 @@ export const toggleCollaboratorPermission = async (page: Page, switchLabel: stri await page.waitForLoadState("networkidle"); await page.getByText(switchLabel, { exact: true }).click(); await page.getByRole("button", { name: "Done" }).click(); - await expect(page.getByText("Updated User Workspace permissions")).toBeVisible(); + await expect(page.getByText("Permissions updated")).toBeVisible(); + + // The Dialog only updates local state, so the change must be persisted via the Workspace `Save` button + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page).toHaveURL("/"); }; /** diff --git a/types/index.d.ts b/types/index.d.ts index b45b4d31..0f603b3d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -792,9 +792,11 @@ export type PermissionsDialogProps = { open: boolean; setOpen: (open: boolean) => void; user: string; + editable?: boolean; // If `false`, show a read-only preview instead of editable toggles isGlobal: boolean; // Define if modifying "global" permissions or just for the Workspace workspace?: string; // Specify the Workspace if modifying Workspace permissions - editable?: boolean; // If `false`, show a read-only preview instead of editable toggles + workspacePermissions?: UserWorkspacePermissions; // Current local Workspace permissions for `user`, used instead of fetching from the server + onUpdateWorkspacePermissions?: (permissions: UserWorkspacePermissions) => void; // Called with the edited permissions instead of persisting them immediately }; // Metrics From a78bc7444f48f58772a8566d45339706bf8c6497 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 10:24:19 -0500 Subject: [PATCH 17/23] MARS-1190 Fix UI bug when selecting menu item --- client/src/components/WorkspaceSwitcher/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/components/WorkspaceSwitcher/index.tsx b/client/src/components/WorkspaceSwitcher/index.tsx index ce0a6d6c..90ec16ce 100644 --- a/client/src/components/WorkspaceSwitcher/index.tsx +++ b/client/src/components/WorkspaceSwitcher/index.tsx @@ -168,6 +168,14 @@ const WorkspaceSwitcher = (props: { id?: string }) => { setOpen(false); }; + /** + * Handle click events within the `Admin` button + */ + const handleAdminClick = () => { + navigate("/admin"); + setOpen(false); + }; + /** * Handle click events within the `Logout` button */ @@ -304,7 +312,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { id={"navAdminButtonMobile"} value={"admin"} fontSize={"xs"} - onClick={() => navigate("/admin")} + onClick={() => handleAdminClick()} cursor={"pointer"} > From 82802ba39e7ad6dfb36b6de0f0b0733bd5f06294 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 14:01:28 -0500 Subject: [PATCH 18/23] MARS-1190 Add loading status to `usePermissions` * Prevent erroneous redirects to `unauthorized` path --- client/src/hooks/usePermissions/index.tsx | 6 ++++-- client/src/pages/create/Entity.tsx | 4 ++-- client/src/pages/create/Project.tsx | 6 +++--- client/src/pages/create/Template.tsx | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/client/src/hooks/usePermissions/index.tsx b/client/src/hooks/usePermissions/index.tsx index 6c126c0e..c093ecf6 100644 --- a/client/src/hooks/usePermissions/index.tsx +++ b/client/src/hooks/usePermissions/index.tsx @@ -52,12 +52,13 @@ const GET_USER_PERMISSIONS = gql` type PermissionsContextValue = { workspacePermissions: UserWorkspacePermissions; globalPermissions: UserGlobalPermissions; + loading: boolean; }; const PermissionsContext = createContext({} as PermissionsContextValue); export const PermissionsProvider = (props: { children: React.JSX.Element }) => { - const { data } = useQuery<{ userCollatedPermissions: UserCollatedPermissions }>(GET_USER_PERMISSIONS, { + const { data, loading } = useQuery<{ userCollatedPermissions: UserCollatedPermissions }>(GET_USER_PERMISSIONS, { fetchPolicy: "network-only", pollInterval: 1000, // Poll every seconds to pick up permission changes }); @@ -66,8 +67,9 @@ export const PermissionsProvider = (props: { children: React.JSX.Element }) => { () => ({ workspacePermissions: data?.userCollatedPermissions.workspace || DEFAULT_WORKSPACE_PERMISSIONS, globalPermissions: data?.userCollatedPermissions.global || DEFAULT_GLOBAL_PERMISSIONS, + loading: loading && !data, }), - [data?.userCollatedPermissions], + [data?.userCollatedPermissions, loading, data], ); return {props.children}; diff --git a/client/src/pages/create/Entity.tsx b/client/src/pages/create/Entity.tsx index 3a4db26d..4f872667 100644 --- a/client/src/pages/create/Entity.tsx +++ b/client/src/pages/create/Entity.tsx @@ -67,7 +67,7 @@ const Entity = () => { const posthog = usePostHog(); // Permissions - const { workspacePermissions } = usePermissions(); + const { workspacePermissions, loading: permissionsLoading } = usePermissions(); const [pageState, setPageState] = useState("start" as "start" | "attributes" | "relationships"); const pageSteps = [ @@ -105,7 +105,7 @@ const Entity = () => { const getUser = async () => { // If the User does not have Workspace permissions, direct to `/unauthorized` - if (!workspacePermissions.entities.create && window.location.pathname !== "/unauthorized") { + if (!permissionsLoading && !workspacePermissions.entities.create && window.location.pathname !== "/unauthorized") { window.location.href = "/unauthorized"; } diff --git a/client/src/pages/create/Project.tsx b/client/src/pages/create/Project.tsx index 0fdbfa76..4329bbc9 100644 --- a/client/src/pages/create/Project.tsx +++ b/client/src/pages/create/Project.tsx @@ -55,7 +55,7 @@ const Project = () => { const posthog = usePostHog(); // Permissions - const { workspacePermissions } = usePermissions(); + const { workspacePermissions, loading: permissionsLoading } = usePermissions(); const [informationOpen, setInformationOpen] = useState(false); const [name, setName] = useState(""); @@ -65,7 +65,7 @@ const Project = () => { const getUser = async () => { // If the User does not have Workspace permissions, direct to `/unauthorized` - if (!workspacePermissions.entities.create && window.location.pathname !== "/unauthorized") { + if (!permissionsLoading && !workspacePermissions.projects.create && window.location.pathname !== "/unauthorized") { window.location.href = "/unauthorized"; } @@ -357,7 +357,7 @@ const Project = () => { setIsSubmitting(true); const response = await createProject({ variables: { - project: { name, owner, archived: false, description, created, entities, collaborators: [] }, + project: { name, owner, archived: false, description, created, entities }, }, }); if (response.data?.createProject.success) { diff --git a/client/src/pages/create/Template.tsx b/client/src/pages/create/Template.tsx index 6cdd2c00..85759a5e 100644 --- a/client/src/pages/create/Template.tsx +++ b/client/src/pages/create/Template.tsx @@ -53,7 +53,7 @@ const Template = () => { const posthog = usePostHog(); // Permissions - const { workspacePermissions } = usePermissions(); + const { workspacePermissions, loading: permissionsLoading } = usePermissions(); const [informationOpen, setInformationOpen] = useState(false); const [name, setName] = useState(""); @@ -65,7 +65,7 @@ const Template = () => { const getUser = async () => { // If the User does not have Workspace permissions, direct to `/unauthorized` - if (!workspacePermissions.templates.create && window.location.pathname !== "/unauthorized") { + if (!permissionsLoading && !workspacePermissions.templates.create && window.location.pathname !== "/unauthorized") { window.location.href = "/unauthorized"; } From 026740f3b29aada17dce1ee5d0ef3821c9a65852 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 14:49:25 -0500 Subject: [PATCH 19/23] MARS-1190 Update `Collaborators` view * Add tag previews for existing permissions * Refactor permissions polling to avoid re-renders * Update default Global permissions --- client/src/components/Collaborators/index.tsx | 42 ++++++---- client/src/hooks/usePermissions/index.tsx | 67 ++++++++++++---- client/src/lib/util.ts | 62 ++++++++++++++ client/src/pages/view/User.tsx | 61 +++++++++++--- client/src/pages/view/Workspace.tsx | 80 +++++++++---------- client/src/variables.ts | 4 +- server/src/models/Admin.ts | 4 +- 7 files changed, 233 insertions(+), 87 deletions(-) diff --git a/client/src/components/Collaborators/index.tsx b/client/src/components/Collaborators/index.tsx index badf18bc..6531306d 100644 --- a/client/src/components/Collaborators/index.tsx +++ b/client/src/components/Collaborators/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Button, EmptyState, Field, Fieldset, Flex, Input, Link, Separator, Stack, Text } from "@chakra-ui/react"; +import { Button, EmptyState, Field, Fieldset, Flex, Input, Link, Separator, Stack, Tag, Text } from "@chakra-ui/react"; // Custom components import ActorTag from "@components/ActorTag"; @@ -17,6 +17,7 @@ import { useLazyQuery, useQuery } from "@apollo/client/react"; // Utility functions import { getCollaboratorPermissions, + getCollaboratorPermissionsLevel, ignoreAbort, isCollaborator, isValidEmail, @@ -245,20 +246,6 @@ const Collaborators = (props: CollaboratorsProps) => { {/* Action Buttons */} {props.editing && ( - {!isOwner && props.currentUser === collaborator._id && ( - - )} - + {!isOwner && props.currentUser === collaborator._id && ( + + )} + {isOwner && ( - + {!isOwner && props.currentUser === collaborator._id && ( )} + + )} - - {/* Permissions Labels */} - - {getCollaboratorPermissionsLevel(collaborator.permissions).map((label) => { - return ( - - {label} - - ); - })} - ))} diff --git a/client/src/pages/account/Admin.tsx b/client/src/pages/account/Admin.tsx index 2700e295..b47824ac 100644 --- a/client/src/pages/account/Admin.tsx +++ b/client/src/pages/account/Admin.tsx @@ -60,8 +60,8 @@ const GET_ADMIN_DATA = gql` description owner entities + projects templates - attributes } } `; @@ -170,7 +170,7 @@ const Admin = () => { }), userColumnHelper.accessor("role", { cell: (info) => ( - + {_.capitalize(info.getValue()) || "User"} ), @@ -291,22 +291,22 @@ const Admin = () => { header: "Entities", meta: { fixedWidth: 90 } as ColumnMeta, }), - workspaceColumnHelper.accessor("templates", { + workspaceColumnHelper.accessor("projects", { cell: (info) => ( {info.getValue()} ), - header: "Templates", + header: "Projects", meta: { fixedWidth: 100 } as ColumnMeta, }), - workspaceColumnHelper.accessor("attributes", { + workspaceColumnHelper.accessor("templates", { cell: (info) => ( {info.getValue()} ), - header: "Attributes", + header: "Templates", meta: { fixedWidth: 100 } as ColumnMeta, }), ]; @@ -333,7 +333,7 @@ const Admin = () => { > - Metadatify Admin Dashboard + Metadatify Administration diff --git a/server/src/models/Admin.ts b/server/src/models/Admin.ts index d9e6c604..5dda51c7 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -128,30 +128,17 @@ export class Admin { }; static getWorkspaces = async (): Promise => { - const [workspaces, entityAttrCounts] = await Promise.all([ - getDatabase().collection(WORKSPACES_COLLECTION).find().toArray(), - getDatabase() - .collection(ENTITIES_COLLECTION) - .aggregate([{ $project: { attrCount: { $size: { $ifNull: ["$attributes", []] } } } }]) - .toArray(), - ]); - - const attrributeCountMap = new Map( - entityAttrCounts.map((entity) => [entity._id as string, entity.attrCount as number]), - ); + const workspaces = await getDatabase().collection(WORKSPACES_COLLECTION).find().toArray(); return workspaces.map((workspace) => { - const entityIds: string[] = workspace.entities || []; - const attributeCount = entityIds.reduce((sum, id) => sum + (attrributeCountMap.get(id) ?? 0), 0); - return { _id: String(workspace._id), name: workspace.name || "", description: workspace.description || "", owner: workspace.owner || "", - entities: entityIds.length, - templates: (workspace.templates || []).length, - attributes: attributeCount, + entities: workspace.entities.length, + projects: workspace.projects.length, + templates: workspace.templates.length, }; }); }; diff --git a/server/src/typedefs.ts b/server/src/typedefs.ts index d3064e53..698fb8f5 100644 --- a/server/src/typedefs.ts +++ b/server/src/typedefs.ts @@ -191,8 +191,8 @@ export const typedefs = `#graphql description: String owner: String entities: Int + projects: Int templates: Int - attributes: Int } # "AdminUser" type diff --git a/types/index.d.ts b/types/index.d.ts index 0f603b3d..e32454ed 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -819,8 +819,8 @@ export type AdminWorkspace = { description: string; owner: string; entities: number; + projects: number; templates: number; - attributes: number; }; export type AdminMetrics = { From 87f0fb09bf24e148f31d7313ae06a478789044eb Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 29 Jul 2026 16:08:24 -0500 Subject: [PATCH 22/23] MARS-1190 Improve resilience of `usePermissions` --- client/src/hooks/usePermissions/index.tsx | 1 + server/src/lib/util.ts | 10 ++++++++-- server/src/models/Admin.ts | 6 +++--- server/test/helpers.ts | 1 - server/test/models/Admin.test.ts | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/src/hooks/usePermissions/index.tsx b/client/src/hooks/usePermissions/index.tsx index cdb9b268..77440b22 100644 --- a/client/src/hooks/usePermissions/index.tsx +++ b/client/src/hooks/usePermissions/index.tsx @@ -77,6 +77,7 @@ const createPermissionsStore = (client: ApolloClient) => { const subscription = observable.subscribe((result) => { if (!result.data?.userCollatedPermissions) return; + if (!result.data.userCollatedPermissions.global || !result.data.userCollatedPermissions.workspace) return; const data = result.data as { userCollatedPermissions: UserCollatedPermissions }; const next: PermissionsContextValue = { diff --git a/server/src/lib/util.ts b/server/src/lib/util.ts index 4a865a28..66218863 100644 --- a/server/src/lib/util.ts +++ b/server/src/lib/util.ts @@ -39,9 +39,15 @@ export const isCollaborator = (_id: string, collaborators: Collaborator[]): bool * * Note: Modifying outside of better-auth means that `permissions` is stored as a JSON string, * mirroring `api_keys` - * @param {UserGlobalPermissions | string} permissions Either a JSON string or `UserGlobalPermissions` instance + * @param {UserGlobalPermissions | string | null | undefined} permissions Either a JSON string or `UserGlobalPermissions` instance, + * possibly not yet populated + * @param {UserGlobalPermissions} fallback Value to use when `permissions` has not been populated yet * @return {UserGlobalPermissions} */ -export const parseGlobalPermissions = (permissions: UserGlobalPermissions | string): UserGlobalPermissions => { +export const parseGlobalPermissions = ( + permissions: UserGlobalPermissions | string | null | undefined, + fallback: UserGlobalPermissions, +): UserGlobalPermissions => { + if (!permissions) return fallback; return typeof permissions === "string" ? JSON.parse(permissions) : permissions; }; diff --git a/server/src/models/Admin.ts b/server/src/models/Admin.ts index 5dda51c7..9f1d15a7 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -101,7 +101,7 @@ export class Admin { } } - const userPermissions = parseGlobalPermissions(user.permissions); + const userPermissions = parseGlobalPermissions(user.permissions, DEFAULT_GLOBAL_PERMISSIONS); const permissions: UserGlobalPermissions = { features: { ai: userPermissions.features.ai, @@ -151,7 +151,7 @@ export class Admin { return DEFAULT_GLOBAL_PERMISSIONS; } - return parseGlobalPermissions(userResult.permissions); + return parseGlobalPermissions(userResult.permissions, DEFAULT_GLOBAL_PERMISSIONS); }; static setUserGlobalPermissions = async ( @@ -313,7 +313,7 @@ export class Admin { }; } - const globalPermissions = parseGlobalPermissions(userResult.permissions); + const globalPermissions = parseGlobalPermissions(userResult.permissions, DEFAULT_GLOBAL_PERMISSIONS); // Check if User is Workspace owner or Collaborator if (workspaceResult.owner === _id) { diff --git a/server/test/helpers.ts b/server/test/helpers.ts index ba64772a..c16143b6 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -129,7 +129,6 @@ export const createTestWorkspace = async (workspaceName: string): Promise { const permissions = await Admin.getUserGlobalPermissions(OWNER_ID); expect(permissions.features.ai).toBeTruthy(); - expect(permissions.features.scan).toBeFalsy(); + expect(permissions.features.scan).toBeTruthy(); }); it("disables a previously enabled Global permission", async () => { From c832f946a959f2e78fb4332367c049975fa05c62 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 30 Jul 2026 08:56:59 -0500 Subject: [PATCH 23/23] MARS-1190 Update `SearchBox` tests with variables --- client/test/components/SearchBox.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/test/components/SearchBox.test.tsx b/client/test/components/SearchBox.test.tsx index 169a0d59..4066ad7a 100644 --- a/client/test/components/SearchBox.test.tsx +++ b/client/test/components/SearchBox.test.tsx @@ -9,14 +9,17 @@ import { render } from "../render"; // Target component import SearchBox from "../../src/components/SearchBox"; +// Variables +import { DEFAULT_GLOBAL_PERMISSIONS, DEFAULT_WORKSPACE_PERMISSIONS } from "../../src/variables"; + vi.mock("react-router-dom", () => ({ useNavigate: vi.fn(() => vi.fn()), })); -vi.mock("@hooks/usePermissions", () => ({ +vi.mock("../../src/hooks/usePermissions", () => ({ usePermissions: vi.fn(() => ({ - workspacePermissions: {}, - globalPermissions: {}, + workspacePermissions: DEFAULT_WORKSPACE_PERMISSIONS, + globalPermissions: DEFAULT_GLOBAL_PERMISSIONS, })), }));