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/Collaborators/index.tsx b/client/src/components/Collaborators/index.tsx index 24835c66..8ce09282 100644 --- a/client/src/components/Collaborators/index.tsx +++ b/client/src/components/Collaborators/index.tsx @@ -1,24 +1,34 @@ 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, Tag, Text } from "@chakra-ui/react"; // Custom components import ActorTag from "@components/ActorTag"; import Icon from "@components/Icon"; +import PermissionsDialog from "@components/PermissionsDialog"; import { toaster } from "@components/Toast"; // Custom types -import { 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 _ from "lodash"; -import { isValidEmail, ignoreAbort } from "@lib/util"; +import { + getCollaboratorPermissions, + getCollaboratorPermissionsLevel, + ignoreAbort, + isCollaborator, + isValidEmail, + setCollaboratorPermissions, +} 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,13 +43,48 @@ 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); + // Flag to enable owner-specific features + const isOwner = props.currentUser === props.owner; + const [addCollaboratorLoading, setAddCollaboratorLoading] = useState(false); + // `PermissionsDialog` state + const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false); + const [permissionsDialogUser, setPermissionsDialogUser] = useState(""); + const [getCollaboratorUserId, { loading: collaboratorQueryLoading, error }] = useLazyQuery<{ userByEmail: ResponseData; }>(GET_USER_BY_EMAIL, { @@ -49,7 +94,7 @@ const Collaborators = (props: CollaboratorsProps) => { const handleAddCollaborator = async () => { setAddCollaboratorLoading(true); // Prevent adding empty or duplicate collaborator - if (newCollaborator && !props.collaborators.includes(newCollaborator)) { + if (newCollaborator && !isCollaborator(newCollaborator, props.collaborators)) { const result = await getCollaboratorUserId({ variables: { email: newCollaborator, @@ -73,8 +118,12 @@ const Collaborators = (props: CollaboratorsProps) => { closable: true, }); } else if (result.data) { - const collaborator = result.data.userByEmail.data; - if (!_.includes(props.collaborators, 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 { @@ -100,7 +149,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 ( @@ -122,34 +171,43 @@ const Collaborators = (props: CollaboratorsProps) => { Collaborators ({props.collaborators.length}) - - - - - setNewCollaborator(event.target.value)} - disabled={!props.editing} - /> - - - - - + + {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 - + + + + Collaborator + + + - {props.editing && - (collaborator === props.currentUser && props.currentUser !== props.owner ? ( - - ) : ( - collaborator !== props.owner && ( + + {/* Action Buttons, including Workspace remove / leave and permissions */} + + {/* Permissions Labels */} + + {getCollaboratorPermissionsLevel(collaborator.permissions).map((label) => { + return ( + + {label} + + ); + })} + + + {/* Action Buttons */} + {props.editing && ( + + {!isOwner && props.currentUser === collaborator._id && ( + + )} + + {isOwner && ( + + )} + - ) - ))} + + )} + ))} )} + + {permissionsDialogUser && ( + + props.setCollaborators((collaborators) => + setCollaboratorPermissions(permissionsDialogUser, permissions, collaborators), + ) + } + /> + )} ); }; 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) => { { // 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.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 3d4b4941..79a2f273 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/PermissionsDialog/index.tsx b/client/src/components/PermissionsDialog/index.tsx new file mode 100644 index 00000000..87892f55 --- /dev/null +++ b/client/src/components/PermissionsDialog/index.tsx @@ -0,0 +1,743 @@ +// React +import React, { useEffect, useState } from "react"; + +// Existing and custom components +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 +import { IResponseMessage, PermissionsDialogProps, UserGlobalPermissions, UserWorkspacePermissions } from "@types"; + +// Hooks +import { usePermissions } from "@hooks/usePermissions"; + +// GraphQL +import { gql } from "@apollo/client"; + +// Variables +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] = useState(globalPermissions.workspaces.create); + + // Workspace-specific permissions for the specified user + const [workspaceEdit, setWorkspaceEdit] = useState(workspacePermissions.administration.edit); + const [workspaceInvite, setWorkspaceInvite] = useState(workspacePermissions.administration.invite); + const [entitiesCreate, setEntitiesCreate] = useState(workspacePermissions.entities.create); + const [entitiesEdit, setEntitiesEdit] = useState(workspacePermissions.entities.edit); + const [entitiesArchive, setEntitiesArchive] = useState(workspacePermissions.entities.archive); + const [projectsCreate, setProjectsCreate] = useState(workspacePermissions.projects.create); + const [projectsEdit, setProjectsEdit] = useState(workspacePermissions.projects.edit); + const [projectsArchive, setProjectsArchive] = useState(workspacePermissions.projects.archive); + const [templatesCreate, setTemplatesCreate] = useState(workspacePermissions.templates.create); + const [templatesEdit, setTemplatesEdit] = useState(workspacePermissions.templates.edit); + const [templatesArchive, setTemplatesArchive] = useState(workspacePermissions.templates.archive); + + // If `isGlobal`, get the permissions of the User we are modifying + const GET_USER_GLOBAL_PERMISSIONS = gql` + query GetUserGlobalPermissions($_id: String) { + userGlobalPermissions(_id: $_id) { + features { + import + scan + ai + api + } + workspaces { + create + } + } + } + `; + + const [getUserGlobalPermissions] = useLazyQuery<{ userGlobalPermissions: UserGlobalPermissions }>( + GET_USER_GLOBAL_PERMISSIONS, + { + fetchPolicy: "network-only", + }, + ); + + const refreshUserGlobalPermissionsState = async (_id: string) => { + const result = await getUserGlobalPermissions({ + variables: { + _id: _id, + }, + }); + + if (result.data) { + setFeaturesImport(result.data.userGlobalPermissions.features.import); + setFeaturesScan(result.data.userGlobalPermissions.features.scan); + setFeaturesAI(result.data.userGlobalPermissions.features.ai); + setFeaturesAPI(result.data.userGlobalPermissions.features.api); + } else { + toaster.create({ + title: "Could not retrieve User 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 if (props.workspacePermissions) { + applyLocalWorkspacePermissions(props.workspacePermissions); + } + }, [props.user, props.open]); + + // Mutation to update User global permissions + const SET_USER_GLOBAL_PERMISSIONS = gql` + mutation UpdateSetGlobalPermissions($_id: String, $permissions: UserGlobalPermissionsInput) { + setUserGlobalPermissions(_id: $_id, permissions: $permissions) { + success + message + } + } + `; + const [setUserGlobalPermissions, { loading: userSetGlobalPermissionsLoading, error: userSetGlobalPermissionsError }] = + useMutation<{ + setUserGlobalPermissions: IResponseMessage; + }>(SET_USER_GLOBAL_PERMISSIONS); + + /** + * Utility function to execute GraphQL manipulations updating the User permissions + */ + const applyPermissions = async () => { + if (props.isGlobal) { + // Execute GraphQL mutation + const result = await setUserGlobalPermissions({ + fetchPolicy: "network-only", + variables: { + _id: props.user, + permissions: { + features: { + import: featuresImport, + scan: featuresScan, + ai: featuresAI, + api: featuresAPI, + }, + workspaces: { + create: workspaceCreate, + }, + }, + }, + }); + + if (userSetGlobalPermissionsError) { + toaster.create({ + title: "Could not update User permissions", + type: "error", + duration: 2000, + closable: true, + }); + } + + if (result.data && result.data.setUserGlobalPermissions.success) { + toaster.create({ + title: "Updated User permissions", + type: "success", + duration: 2000, + closable: true, + }); + + // Close the dialog + props.setOpen(false); + } + } else { + // 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, + }, + }); + + // Close the dialog + props.setOpen(false); + } + }; + + return ( + props.setOpen(event.open)} + size={!editable || props.isGlobal ? "md" : "lg"} + closeOnEscape + closeOnInteractOutside + > + + + + + + + + + {editable ? "Edit" : "View"} {props.isGlobal ? "Global" : "Workspace"} Permissions + + + + + props.setOpen(false)} /> + + + + + + + {props.isGlobal && ( + + )} + {!props.isGlobal && editable && ( + + )} + {!editable && ( + + )} + + {/* Global Permissions */} + {props.isGlobal && ( + + {/* Application Permissions */} + + + Application Permissions + + setFeaturesImport(event.checked)} + colorPalette={"green"} + > + + + + + + + + Import: Enable import of external files + + + + setFeaturesScan(event.checked)} + colorPalette={"green"} + > + + + + + + + + Scan: Enable physical label scanning + + + + setFeaturesAI(event.checked)} + colorPalette={"green"} + > + + + + + + + + AI: Enable AI-assisted features + + + + setFeaturesAPI(event.checked)} + colorPalette={"green"} + > + + + + + + + + API: Use API functionality + + + + + + )} + + {/* Workspace Permissions preview, shown instead of the toggles when not editable */} + {!props.isGlobal && !editable && ( + + + + Workspace + + + + + + + + + + Entities + + + + + + + + + + + Projects + + + + + + + + + + + Templates + + + + + + + )} + + {/* Workspace Permissions */} + {!props.isGlobal && editable && ( + + + {/* Workspace Permissions */} + + + Workspace Permissions + + setWorkspaceEdit(event.checked)} + colorPalette={"green"} + > + + + + + + + + Edit Workspace Details + + + + setWorkspaceInvite(event.checked)} + colorPalette={"green"} + > + + + + + + + + Invite Collaborators + + + + + + + + {/* Entities Permissions */} + + + Entities + + {/* Create Entities */} + setEntitiesCreate(event.checked)} + colorPalette={"green"} + > + + + + + + + + Create Entities + + + + + {/* Edit Entities */} + setEntitiesEdit(event.checked)} + colorPalette={"green"} + > + + + + + + + + Edit Entities + + + + + {/* Archive Entities */} + setEntitiesArchive(event.checked)} + colorPalette={"green"} + > + + + + + + + + Archive Entities + + + + + + {/* Projects Permissions */} + + + Projects + + {/* Create Projects */} + setProjectsCreate(event.checked)} + colorPalette={"green"} + > + + + + + + + + Create Projects + + + + + {/* Edit Projects */} + setProjectsEdit(event.checked)} + colorPalette={"green"} + > + + + + + + + + Edit Projects + + + + + {/* Archive Projects */} + setProjectsArchive(event.checked)} + colorPalette={"green"} + > + + + + + + + + Archive Projects + + + + + + {/* Templates Permissions */} + + + Templates + + {/* Create Templates */} + setTemplatesCreate(event.checked)} + colorPalette={"green"} + > + + + + + + + + Create Templates + + + + + {/* Edit Templates */} + setTemplatesEdit(event.checked)} + colorPalette={"green"} + > + + + + + + + + Edit Templates + + + + + {/* Archive Templates */} + setTemplatesArchive(event.checked)} + colorPalette={"green"} + > + + + + + + + + Archive Templates + + + + + + + )} + + + + + + {editable && ( + + )} + + + + + + + + ); +}; + +export default PermissionsDialog; diff --git a/client/src/components/SearchBox/index.tsx b/client/src/components/SearchBox/index.tsx index 810577d0..a6c8db43 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.features.ai) { const translation = await runTranslateSearch({ variables: { query } }).catch(ignoreAbort); if (!translation) { @@ -254,18 +256,20 @@ const SearchBox = () => { : undefined} + startElement={ + globalPermissions.features.ai ? : undefined + } > { setQuery(event.target.value); setOpen(false); @@ -286,7 +290,7 @@ const SearchBox = () => { data-search-button size={"xs"} rounded={"md"} - colorPalette={features.ai ? "purple" : "green"} + colorPalette={globalPermissions.features.ai ? "purple" : "green"} disabled={query === ""} loading={isSearching} loadingText={"Searching..."} diff --git a/client/src/components/WorkspaceSwitcher/index.tsx b/client/src/components/WorkspaceSwitcher/index.tsx index f84dbcad..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 */ @@ -279,7 +287,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { > - Edit workspace + Manage workspace handleCreateClick()} cursor={"pointer"}> @@ -304,12 +312,12 @@ const WorkspaceSwitcher = (props: { id?: string }) => { id={"navAdminButtonMobile"} value={"admin"} fontSize={"xs"} - onClick={() => navigate("/admin")} + onClick={() => handleAdminClick()} cursor={"pointer"} > - Management + Administration Tools )} 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..77440b22 --- /dev/null +++ b/client/src/hooks/usePermissions/index.tsx @@ -0,0 +1,120 @@ +import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react"; +import _ from "lodash"; + +// GraphQL +import { ApolloClient, gql } from "@apollo/client"; +import { useApolloClient } from "@apollo/client/react"; + +// Custom types +import { UserCollatedPermissions, UserGlobalPermissions, UserWorkspacePermissions } from "@types"; + +// Variables +import { DEFAULT_GLOBAL_PERMISSIONS, DEFAULT_WORKSPACE_PERMISSIONS } from "@variables"; + +const GET_USER_PERMISSIONS = gql` + query GetUserPermissions { + userCollatedPermissions { + workspace { + administration { + edit + invite + } + entities { + create + edit + archive + } + projects { + create + edit + archive + } + templates { + create + edit + archive + } + } + global { + features { + import + scan + ai + api + } + workspaces { + create + } + } + } + } +`; + +type PermissionsContextValue = { + workspacePermissions: UserWorkspacePermissions; + globalPermissions: UserGlobalPermissions; + loading: boolean; +}; + +const PermissionsContext = createContext({} as PermissionsContextValue); + +/** + * Store that polls User permissions outside of React's render cycle + */ +const createPermissionsStore = (client: ApolloClient) => { + let snapshot: PermissionsContextValue = { + workspacePermissions: DEFAULT_WORKSPACE_PERMISSIONS, + globalPermissions: DEFAULT_GLOBAL_PERMISSIONS, + loading: true, + }; + const listeners = new Set<() => void>(); + + const observable = client.watchQuery<{ userCollatedPermissions: UserCollatedPermissions }>({ + query: GET_USER_PERMISSIONS, + fetchPolicy: "network-only", + pollInterval: 1000, // Poll every second to pick up permission changes made by other users + }); + + 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 = { + workspacePermissions: data.userCollatedPermissions.workspace, + globalPermissions: data.userCollatedPermissions.global, + loading: false, + }; + + // Skip notifying subscribers entirely when nothing has actually changed + if (_.isEqual(next, snapshot)) return; + + snapshot = next; + listeners.forEach((listener) => listener()); + }); + + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + subscription.unsubscribe(); + } + }; + }, + getSnapshot: () => snapshot, + }; +}; + +export const PermissionsProvider = (props: { children: React.JSX.Element }) => { + const client = useApolloClient(); + const store = useMemo(() => createPermissionsStore(client), [client]); + const value = useSyncExternalStore(store.subscribe, store.getSnapshot); + + return {props.children}; +}; + +export const usePermissions = () => { + return useContext(PermissionsContext); +}; diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index c55da6b8..26804d68 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -36,6 +36,10 @@ export const auth = createAuthClient({ completedProfile: { type: "boolean", }, + permissions: { + type: "json", + input: false, + }, }, }), ], diff --git a/client/src/lib/util.ts b/client/src/lib/util.ts index 51b3556c..c38675ba 100644 --- a/client/src/lib/util.ts +++ b/client/src/lib/util.ts @@ -3,6 +3,7 @@ import _ from "lodash"; // Custom types import { + Collaborator, IAttribute, ISelectOption, IValue, @@ -11,6 +12,7 @@ import { SearchAttributeValue, SearchQuery, UserModel, + UserWorkspacePermissions, } from "@types"; // Utility functions @@ -74,6 +76,114 @@ export const isValidUser = (user: UserModel): boolean => { return true; }; +/** + * 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; +}; + +/** + * 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, + ); +}; + +/** + * Utility function to check if a User has permissions across "create", "edit", and "archive" for + * a specific category of metadata + * @param permissions Set of User's Workspace permissions + * @param category Type of metadata category within the User's permissions + * @return {boolean} + */ +const isModifyAll = ( + permissions: UserWorkspacePermissions, + category: "entities" | "projects" | "templates", +): boolean => { + const permissionsCategory = permissions[category]; + return permissionsCategory.create && permissionsCategory.edit && permissionsCategory.archive; +}; + +/** + * Utility function to check if a User has some permissions across "create", "edit", and "archive" for + * a specific category of metadata + * @param permissions Set of User's Workspace permissions + * @param category Type of metadata category within the User's permissions + * @return {boolean} + */ +const isModifyPartial = ( + permissions: UserWorkspacePermissions, + category: "entities" | "projects" | "templates", +): boolean => { + const permissionsCategory = permissions[category]; + return permissionsCategory.create || permissionsCategory.edit || permissionsCategory.archive; +}; + +/** + * Generate a set of strings ("View", "Modify (All)", "Modify (Partial)", "Administration") + * depending on the `UserWorkspacePermissions` object, used to generate tags or labels + * @param {UserWorkspacePermissions} permissions Set of User's Workspace permissions + * @return {string[]} + */ +export const getCollaboratorPermissionsLevel = (permissions: UserWorkspacePermissions): string[] => { + const permissionsLabels = ["View"]; + + // "Modify (All)" only shown if all Entities, Projects, and Templates permissions enabled + if ( + isModifyAll(permissions, "entities") && + isModifyAll(permissions, "projects") && + isModifyAll(permissions, "templates") + ) { + permissionsLabels.push("Modify (All)"); + } else if ( + isModifyPartial(permissions, "entities") || + isModifyPartial(permissions, "projects") || + isModifyPartial(permissions, "templates") + ) { + permissionsLabels.push("Modify (Partial)"); + } + + // Administration permissions + if (permissions.administration.edit || permissions.administration.invite) { + permissionsLabels.push("Administration"); + } + + return permissionsLabels; +}; + /** * Check if an ORCID is a valid format * @param {string} orcid the ORCID to check diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 38a3f1c6..613139d1 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -502,7 +502,7 @@ const Dashboard = () => { Dashboard - + {workspaceName} diff --git a/client/src/pages/Search.tsx b/client/src/pages/Search.tsx index 12b4d37b..8e1cd4d3 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.features.ai) setIsAISearch(false); + }, [globalPermissions.features.ai]); // Include archived Entities const [showArchived, setShowArchived] = useState(false); @@ -600,7 +603,7 @@ const Search = () => { Search - + {workspaceName} @@ -870,7 +873,7 @@ const Search = () => { }} /> - {features.ai && ( + {globalPermissions.features.ai && ( ), header: "Permissions", - meta: { minWidth: 300 } as ColumnMeta, + meta: { minWidth: 200 } as ColumnMeta, }), ]; const workspacesTableColumns = [ workspaceColumnHelper.accessor("name", { cell: (info) => ( - - {info.getValue() || "—"} - + + + {_.truncate(info.getValue(), { length: 28 })} + + ), header: "Name", - meta: { minWidth: 180 } as ColumnMeta, + meta: { minWidth: 200 } as ColumnMeta, }), workspaceColumnHelper.accessor("description", { cell: (info) => { const value = info.getValue(); if (value) { return ( - + - {_.truncate(value, { length: 48 })} + {_.truncate(value, { length: 32 })} ); @@ -314,28 +291,34 @@ 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, }), ]; return ( + { > - Metadatify Admin Dashboard + Metadatify Administration diff --git a/client/src/pages/create/Create.tsx b/client/src/pages/create/Create.tsx index da7360f8..98c4404f 100644 --- a/client/src/pages/create/Create.tsx +++ b/client/src/pages/create/Create.tsx @@ -5,10 +5,14 @@ import React from "react"; import { Button, Card, Flex, Heading, Separator, Stack, Tag, Text } from "@chakra-ui/react"; import { Content } from "@components/Container"; import Icon from "@components/Icon"; +import Tooltip from "@components/Tooltip"; // Routing and navigation import { useNavigate } from "react-router-dom"; +// Hooks +import { usePermissions } from "@hooks/usePermissions"; + // Posthog import { usePostHog } from "posthog-js/react"; @@ -19,6 +23,9 @@ const Create = () => { const posthog = usePostHog(); const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + return ( @@ -95,19 +102,26 @@ const Create = () => { - + + @@ -162,19 +176,26 @@ const Create = () => { - + + @@ -224,19 +245,26 @@ const Create = () => { - + + diff --git a/client/src/pages/create/Entity.tsx b/client/src/pages/create/Entity.tsx index 932bfdb7..4f872667 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, loading: permissionsLoading } = 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 (!permissionsLoading && !workspacePermissions.entities.create && window.location.pathname !== "/unauthorized") { + window.location.href = "/unauthorized"; + } + const sessionResponse = await auth.getSession(); if (sessionResponse.error || !sessionResponse.data) { toaster.create({ @@ -930,22 +941,28 @@ const Entity = () => { )} - + + { const posthog = usePostHog(); + // Permissions + const { workspacePermissions, loading: permissionsLoading } = usePermissions(); + const [informationOpen, setInformationOpen] = useState(false); const [name, setName] = useState(""); const [created, setCreated] = useState(dayjs(Date.now()).format("YYYY-MM-DDTHH:mm")); @@ -57,6 +64,11 @@ const Project = () => { const [description, setDescription] = useState(""); const getUser = async () => { + // If the User does not have Workspace permissions, direct to `/unauthorized` + if (!permissionsLoading && !workspacePermissions.projects.create && window.location.pathname !== "/unauthorized") { + window.location.href = "/unauthorized"; + } + const sessionResponse = await auth.getSession(); if (sessionResponse.error || !sessionResponse.data) { toaster.create({ @@ -329,30 +341,36 @@ const Project = () => { - + + {/* Add Entities dialog */} @@ -426,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..85759a5e 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, loading: permissionsLoading } = 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 (!permissionsLoading && !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 = () => { - + + { Activity - + {workspaceName} diff --git a/client/src/pages/view/Entities.tsx b/client/src/pages/view/Entities.tsx index 3cc89d11..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(""); @@ -349,17 +353,29 @@ const Entities = () => { Entities - + {workspaceName} - + + + 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 faca293b..96c05ebc 100644 --- a/client/src/pages/view/Project.tsx +++ b/client/src/pages/view/Project.tsx @@ -26,7 +26,6 @@ import { SkeletonText, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; -import Collaborators from "@components/Collaborators"; import { Content } from "@components/Container"; import ExportDialog from "@components/ExportDialog"; import Icon from "@components/Icon"; @@ -40,24 +39,30 @@ import Tooltip from "@components/Tooltip"; import { UnsavedChangesDialog } from "@components/UnsavedChangesDialog"; import { toaster } from "@components/Toast"; import SaveDialog from "@components/SaveDialog"; +import { createColumnHelper } from "@tanstack/react-table"; // Existing and custom types -import { ProjectHistory, ProjectModel, DataTableAction, IGenericItem, ResponseData } from "@types"; -import { Cell } from "@tanstack/react-table"; +import { + ProjectHistory, + ProjectModel, + DataTableAction, + IGenericItem, + ResponseData, + EntityModel, + AttributeModel, +} from "@types"; // Apollo client imports import { gql } from "@apollo/client"; -import { useQuery, useMutation } from "@apollo/client/react"; +import { useQuery, useMutation, useApolloClient } from "@apollo/client/react"; // Routing and navigation import { useParams, useNavigate, useBlocker } from "react-router-dom"; // Hooks +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; -// Authentication -import { auth } from "@lib/auth"; - // Utility functions and libraries import { removeTypename } from "@lib/util"; import _ from "lodash"; @@ -66,8 +71,19 @@ import dayjs from "dayjs"; // Variables import { GLOBAL_STYLES } from "@variables"; +// Row shape for the Entities table; description and attributes are undefined until fetched +type EntityTableRow = { + _id: string; + description?: string; + attributes?: AttributeModel[]; +}; + const Project = () => { const { id } = useParams(); + const client = useApolloClient(); + + // Permissions + const { workspacePermissions } = usePermissions(); // Workspace information const { workspace } = useWorkspace(); @@ -81,15 +97,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 +124,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 +161,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 +174,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 +185,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 +230,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 +257,7 @@ const Project = () => { `; const { loading, error, data } = useQuery<{ project: ProjectModel; - entities: IGenericItem[]; + projectEntities: EntityModel[]; workspace: IGenericItem; }>(GET_PROJECT_WITH_ENTITIES, { variables: { @@ -257,6 +267,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 +322,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 +350,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 +396,6 @@ const Project = () => { archived: projectArchived, description: projectDescription, owner: project.owner, - collaborators: projectCollaborators || [], created: project.created, entities: projectEntities, history: projectHistory, @@ -374,7 +411,6 @@ const Project = () => { archived: updateData.archived, created: updateData.created, owner: updateData.owner, - collaborators: updateData.collaborators, description: updateData.description, entities: updateData.entities, }), @@ -476,7 +512,6 @@ const Project = () => { setProjectDescription(project.description); setProjectEntities(project.entities); setProjectHistory(project.history); - setProjectCollaborators(project.collaborators); }; /** @@ -492,7 +527,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 +543,6 @@ const Project = () => { archived: updateData.archived, created: updateData.created, owner: updateData.owner, - collaborators: updateData.collaborators, description: updateData.description, entities: updateData.entities, }), @@ -541,7 +574,6 @@ const Project = () => { setProjectDescription(updateData.description); setProjectEntities(updateData.entities); setProjectHistory(updateData?.history || []); - setProjectCollaborators(updateData?.collaborators || []); setIsLoaded(true); }; @@ -581,17 +613,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 ? ( + + + + + ) : ( {editing && ( @@ -824,19 +925,25 @@ const Project = () => { )} - + + )} @@ -1125,17 +1232,27 @@ const Project = () => { Preview - + + @@ -1219,39 +1336,6 @@ const Project = () => { )} - - - - Collaborators - - {projectVersion.collaborators.length > 0 ? ( - - {projectVersion.collaborators.map((collaborator) => ( - - {collaborator} - - ))} - - ) : ( - - - No Collaborators - - - )} - @@ -1403,7 +1487,7 @@ const Project = () => { - {/* Project Entities and Collaborators */} + {/* Project Entities */} {/* Entities */} { > {displayProjectEntities && displayProjectEntities.length > 0 ? ( { )} - - {/* Collaborators */} - - - diff --git a/client/src/pages/view/Projects.tsx b/client/src/pages/view/Projects.tsx index 37de322d..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(""); @@ -293,17 +297,29 @@ const Projects = () => { Projects - + {workspaceName} - + + + diff --git a/client/src/pages/view/Template.tsx b/client/src/pages/view/Template.tsx index 9ce92148..0b24cf8b 100644 --- a/client/src/pages/view/Template.tsx +++ b/client/src/pages/view/Template.tsx @@ -54,6 +54,7 @@ import { gql } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client/react"; // Hooks +import { usePermissions } from "@hooks/usePermissions"; import { useWorkspace } from "@hooks/useWorkspace"; // Variables @@ -63,6 +64,9 @@ 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 7160396d..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(""); @@ -300,17 +304,29 @@ const Templates = () => { Templates - + {workspaceName} - + + + diff --git a/client/src/pages/view/User.tsx b/client/src/pages/view/User.tsx index 2b33b287..15ad4d8b 100644 --- a/client/src/pages/view/User.tsx +++ b/client/src/pages/view/User.tsx @@ -31,9 +31,9 @@ import { APIKey, DataTableAction, IResponseMessage, ResponseData, UserModel, Wor import { gql } from "@apollo/client"; import { useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; -// Context and hooks +// Hooks import { useBreakpoint } from "@hooks/useBreakpoint"; -import { useFeatures } from "@hooks/useFeatures"; +import { usePermissions } from "@hooks/usePermissions"; // Authentication import { auth } from "@lib/auth"; @@ -41,14 +41,16 @@ import { auth } from "@lib/auth"; // Utility functions and libraries import _ from "lodash"; import dayjs from "dayjs"; -import { isValidEmail, ignoreAbort } from "@lib/util"; +import { isValidEmail, ignoreAbort, isCollaborator } from "@lib/util"; // Variables import { APP_URL, GLOBAL_STYLES } from "@variables"; const User = () => { const { isBreakpointActive } = useBreakpoint(); - const { features } = useFeatures(); + + // Permissions + const { globalPermissions } = usePermissions(); // Authentication and user const [user, setUser] = useState(""); @@ -87,6 +89,7 @@ const User = () => { affiliation api_keys account_orcid + role } workspaces { _id @@ -94,7 +97,9 @@ const User = () => { description public owner - collaborators + collaborators { + _id + } } } `; @@ -487,7 +492,7 @@ const User = () => { name: workspace.name, description: workspace.description, public: workspace.public, - collaborators: workspace.collaborators.filter((c) => c !== user), + collaborators: workspace.collaborators.filter((c) => !isCollaborator(c._id, workspace.collaborators)), }, }, }); @@ -521,7 +526,7 @@ const User = () => { name: w.name, description: w.description, public: w.public, - collaborators: w.collaborators.filter((c) => c !== user), + collaborators: w.collaborators.filter((c) => !isCollaborator(c._id, w.collaborators)), }, }, }), @@ -717,19 +722,53 @@ const User = () => { align={"center"} wrap={"wrap"} > - - - - {staticName} - + + + + + {staticName} + + + + {userModel.role === "admin" && ( + + + Administrator + + + )} + + {userModel.role !== "admin" && ( + + + Standard User + + + )} {editing ? ( @@ -1061,7 +1100,7 @@ const User = () => { - {features.api && ( + {globalPermissions.features.api && ( { const navigate = useNavigate(); + // Permissions + const { workspacePermissions } = usePermissions(); + // Query to get a Workspace const GET_WORKSPACE = gql` query GetWorkspace($_id: String) { @@ -50,7 +55,30 @@ const Workspace = () => { public timestamp description - collaborators + collaborators { + _id + permissions { + administration { + edit + invite + } + entities { + create + edit + archive + } + projects { + create + edit + archive + } + templates { + create + edit + archive + } + } + } } } `; @@ -175,10 +203,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); @@ -260,58 +288,56 @@ const Workspace = () => { const handleUpdateClick = async () => { await updateWorkspace({ variables: { - workspace: { + workspace: removeTypename({ _id: workspace, name: name, description: description, owner: owner, public: isPublic, collaborators: collaborators, - }, - }, - }); - - // Update Entity archive state - await archiveEntitiesQuery({ - variables: { - toArchive: entities.filter((entity) => entity.archived === true).map((entity) => entity._id), - state: true, - }, - }); - await archiveEntitiesQuery({ - variables: { - toArchive: entities.filter((entity) => entity.archived === false).map((entity) => entity._id), - state: false, - }, - }); - - // Update Project archive state - await archiveProjectsQuery({ - variables: { - toArchive: projects.filter((project) => project.archived === true).map((project) => project._id), - state: true, - }, - }); - await archiveProjectsQuery({ - variables: { - toArchive: projects.filter((project) => project.archived === false).map((project) => project._id), - state: false, + }), }, }); - // Update Template archive state - await archiveTemplatesQuery({ - variables: { - toArchive: templates.filter((template) => template.archived === true).map((template) => template._id), - state: true, - }, - }); - await archiveTemplatesQuery({ - variables: { - toArchive: templates.filter((template) => template.archived === false).map((template) => template._id), - state: false, - }, - }); + // Update Entity, Project, and Template archive state; each pair of calls is mutually exclusive so all six can run concurrently + await Promise.all([ + archiveEntitiesQuery({ + variables: { + toArchive: entities.filter((entity) => entity.archived === true).map((entity) => entity._id), + state: true, + }, + }), + archiveEntitiesQuery({ + variables: { + toArchive: entities.filter((entity) => entity.archived === false).map((entity) => entity._id), + state: false, + }, + }), + archiveProjectsQuery({ + variables: { + toArchive: projects.filter((project) => project.archived === true).map((project) => project._id), + state: true, + }, + }), + archiveProjectsQuery({ + variables: { + toArchive: projects.filter((project) => project.archived === false).map((project) => project._id), + state: false, + }, + }), + archiveTemplatesQuery({ + variables: { + toArchive: templates.filter((template) => template.archived === true).map((template) => template._id), + state: true, + }, + }), + archiveTemplatesQuery({ + variables: { + toArchive: templates.filter((template) => template.archived === false).map((template) => template._id), + state: false, + }, + }), + ]); if (workspaceUpdateError) { toaster.create({ @@ -432,17 +458,24 @@ const Workspace = () => { - + + + + + + - - + {workspacePermissions.administration.edit && ( + + + + + )} @@ -724,6 +773,7 @@ const Workspace = () => { rounded={"md"} placeholder={"Name"} value={name} + disabled={!workspacePermissions.administration.edit} onChange={(event) => setName(event.target.value)} /> @@ -789,6 +839,7 @@ const Workspace = () => { value={description} size={"xs"} h={"100%"} + disabled={!workspacePermissions.administration.edit} onChange={(event) => setDescription(event.target.value)} /> diff --git a/client/src/variables.ts b/client/src/variables.ts index cd967b18..fb42e894 100644 --- a/client/src/variables.ts +++ b/client/src/variables.ts @@ -1,6 +1,46 @@ /** * Specify important application-wide variables */ + +// Custom types +import { UserGlobalPermissions, UserWorkspacePermissions } from "@types"; + +// Default Workspace permissions, mirrors server variables +export const DEFAULT_WORKSPACE_PERMISSIONS: UserWorkspacePermissions = { + administration: { + 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 = { + features: { + import: true, + scan: true, + ai: false, + api: false, + }, + workspaces: { + create: 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/client/test/components/SearchBox.test.tsx b/client/test/components/SearchBox.test.tsx index 0f88bfe3..4066ad7a 100644 --- a/client/test/components/SearchBox.test.tsx +++ b/client/test/components/SearchBox.test.tsx @@ -9,12 +9,18 @@ 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/useFeatures", () => ({ - useFeatures: vi.fn(() => ({ features: { ai: true, api: false } })), +vi.mock("../../src/hooks/usePermissions", () => ({ + usePermissions: vi.fn(() => ({ + workspacePermissions: DEFAULT_WORKSPACE_PERMISSIONS, + globalPermissions: DEFAULT_GLOBAL_PERMISSIONS, + })), })); const createTestCache = () => { 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/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); + }); +}); 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..73d487ae 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); @@ -266,7 +298,6 @@ export const openItemFromTable = async ( const allItems = await table.locator("text").allTextContents(); throw new Error(`Item "${itemName}" not found in table. Available items: ${allItems.slice(0, 5).join(", ")}...`); } - await textLocator.scrollIntoViewIfNeeded(); // Find buttons with the correct aria-label const count = await buttons.count(); @@ -301,10 +332,8 @@ export const openItemFromTable = async ( throw new Error(`Could not find button "${viewButtonLabel}" in row containing "${itemName}"`); } - await closestBtn.scrollIntoViewIfNeeded(); await closestBtn.click(); } else { - await buttons.first().scrollIntoViewIfNeeded(); await buttons.first().click(); } }; diff --git a/client/test/integration/helpers/permissions.helpers.ts b/client/test/integration/helpers/permissions.helpers.ts new file mode 100644 index 00000000..b996d832 --- /dev/null +++ b/client/test/integration/helpers/permissions.helpers.ts @@ -0,0 +1,166 @@ +// 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(); + + // 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 page.waitForLoadState("networkidle"); + await expect(page).toHaveURL("/"); +}; + +/** + * 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/client/test/integration/workflows/entity.test.ts b/client/test/integration/workflows/entity.test.ts index c2ca5db3..3ea8e71c 100644 --- a/client/test/integration/workflows/entity.test.ts +++ b/client/test/integration/workflows/entity.test.ts @@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test"; // Test helper functions -import { getUniqueName, createTestUser, createTestWorkspace, switchWorkspace } from "../helpers"; +import { getUniqueName, createTestUser, createTestWorkspace, switchWorkspace } from "../helpers/global.helpers"; test.describe("Entity", () => { test.describe("Create", () => { diff --git a/client/test/integration/workflows/import.test.ts b/client/test/integration/workflows/import.test.ts index 295702da..7a758e45 100644 --- a/client/test/integration/workflows/import.test.ts +++ b/client/test/integration/workflows/import.test.ts @@ -9,7 +9,7 @@ import { createTestWorkspace, switchWorkspace, createTestProject, -} from "../helpers"; +} from "../helpers/global.helpers"; // Other imports import * as path from "path"; diff --git a/client/test/integration/workflows/project.test.ts b/client/test/integration/workflows/project.test.ts index 67e9c91d..fa78cdcc 100644 --- a/client/test/integration/workflows/project.test.ts +++ b/client/test/integration/workflows/project.test.ts @@ -2,7 +2,7 @@ import test, { expect } from "@playwright/test"; // Test helper functions -import { getUniqueName, createTestUser, createTestWorkspace, switchWorkspace } from "../helpers"; +import { getUniqueName, createTestUser, createTestWorkspace, switchWorkspace } from "../helpers/global.helpers"; test.describe("Project", () => { test.describe("Create", () => { diff --git a/client/test/integration/workflows/query.test.ts b/client/test/integration/workflows/query.test.ts index 5c8849aa..c1226cfb 100644 --- a/client/test/integration/workflows/query.test.ts +++ b/client/test/integration/workflows/query.test.ts @@ -8,7 +8,7 @@ import { createTestWorkspace, createTestUser, selectChakraSelectOption, -} from "../helpers"; +} from "../helpers/global.helpers"; test.describe("Search Query Builder", () => { test.beforeEach(async ({ context, page }) => { diff --git a/client/test/integration/workflows/template.test.ts b/client/test/integration/workflows/template.test.ts index 54b539c2..2744b902 100644 --- a/client/test/integration/workflows/template.test.ts +++ b/client/test/integration/workflows/template.test.ts @@ -13,7 +13,7 @@ import { createTestWorkspace, createTestUser, switchWorkspace, -} from "../helpers"; +} from "../helpers/global.helpers"; test.describe("Template", () => { test.describe("Create", () => { 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 8e71edd1..66218863 100644 --- a/server/src/lib/util.ts +++ b/server/src/lib/util.ts @@ -1,3 +1,6 @@ +// Custom types +import { Collaborator, UserGlobalPermissions } from "@types"; + // Utility libraries and functions import { nanoid } from "nanoid"; @@ -13,3 +16,38 @@ 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; +}; + +/** + * 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 | 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 | 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 5bbdebfa..9f1d15a7 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -1,9 +1,27 @@ // Custom types -import { AdminMetrics, AdminUser, AdminWorkspace, IResponseMessage, UserFeatures } from "@types"; +import { + AdminMetrics, + AdminUser, + AdminWorkspace, + Collaborator, + IResponseMessage, + UserCollatedPermissions, + UserGlobalPermissions, + UserModel, + UserWorkspacePermissions, + WorkspaceModel, +} from "@types"; + +// Models +import { User } from "./User"; // Database import { getDatabase } from "@connectors/database"; +// Utility functions and libraries +import { parseGlobalPermissions } from "@lib/util"; +import _ from "lodash"; + // Collection names const USERS_COLLECTION = "user"; const WORKSPACES_COLLECTION = "workspaces"; @@ -11,6 +29,42 @@ const ENTITIES_COLLECTION = "entities"; const PROJECTS_COLLECTION = "projects"; const TEMPLATES_COLLECTION = "templates"; +// Default Workspace permissions +export const DEFAULT_WORKSPACE_PERMISSIONS: UserWorkspacePermissions = { + administration: { + 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 +export const DEFAULT_GLOBAL_PERMISSIONS: UserGlobalPermissions = { + features: { + import: true, + scan: true, + ai: false, + api: false, + }, + workspaces: { + create: false, + }, +}; + export class Admin { static getMetrics = async (): Promise => { const [users, workspaces, entities, projects, templates] = await Promise.all([ @@ -26,24 +80,38 @@ export class Admin { static getUsers = async (): Promise => { const [users, workspaces] = await Promise.all([ - getDatabase().collection(USERS_COLLECTION).find().toArray(), + getDatabase().collection(USERS_COLLECTION).find().toArray(), getDatabase() - .collection(WORKSPACES_COLLECTION) + .collection(WORKSPACES_COLLECTION) .find({}, { projection: { owner: 1, collaborators: 1 } }) .toArray(), ]); return users.map((user) => { const userId = String(user._id); - const workspaceCount = workspaces.filter( - (workspace) => - workspace.owner === userId || - (Array.isArray(workspace.collaborators) && workspace.collaborators.includes(userId)), - ).length; - - const features: UserFeatures = { - ai: user.features?.ai ?? false, - api: user.features?.api ?? false, + + // Count the number of Workspaces owned by the User + const workspaceOwnerCount = workspaces.filter((workspace: WorkspaceModel) => workspace.owner === userId).length; + + // Count the number of Workspaces the User is a collaborator on + let workspaceCollaboratorCount = 0; + for (const workspace of workspaces) { + if (workspace.collaborators.find((collaborator: Collaborator) => collaborator._id === userId)) { + workspaceCollaboratorCount++; + } + } + + const userPermissions = parseGlobalPermissions(user.permissions, DEFAULT_GLOBAL_PERMISSIONS); + const permissions: UserGlobalPermissions = { + features: { + ai: userPermissions.features.ai, + api: userPermissions.features.api, + import: userPermissions.features.import, + scan: userPermissions.features.scan, + }, + workspaces: { + create: userPermissions.workspaces.create, + }, }; return { @@ -51,8 +119,8 @@ export class Admin { name: user.name || "", email: user.email || "", role: user.role || "user", - workspaces: workspaceCount, - features, + workspaces: workspaceOwnerCount + workspaceCollaboratorCount, + permissions, banned: user.banned ?? false, lastLogin: user.lastLogin || "", }; @@ -60,57 +128,243 @@ 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, }; }); }; - static getCurrentUserFeatures = async (_id: string): Promise => { - const user = await getDatabase().collection(USERS_COLLECTION).findOne({ _id: _id }); + 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 parseGlobalPermissions(userResult.permissions, DEFAULT_GLOBAL_PERMISSIONS); + }; + + static setUserGlobalPermissions = async ( + _id: string, + permissions: UserGlobalPermissions, + ): Promise => { + const user = await User.getOne(_id); + + if (_.isNull(user)) { + return { + success: false, + message: "User not found", + }; + } + + const update: { $set: UserModel } = { + $set: { + ...user, + permissions: JSON.stringify(permissions) as unknown as UserGlobalPermissions, + }, + }; + + const response = await getDatabase().collection(USERS_COLLECTION).updateOne({ _id: _id }, update); + const successStatus = response.modifiedCount === 1 || response.matchedCount === 1; + return { - ai: user?.features?.ai ?? false, - api: user?.features?.api ?? false, + success: successStatus, + message: successStatus ? "Updated User permissions successfully" : "Unable to update User permissions", }; }; - static setUserFeatures = async (_id: string, features: Partial): Promise => { - const update: Record = {}; - if (features.ai !== undefined) update["features.ai"] = features.ai; - if (features.api !== undefined) update["features.api"] = features.api; + static getUserWorkspacePermissions = async (_id: string, workspace: string): Promise => { + const workspaceResult = await getDatabase() + .collection(WORKSPACES_COLLECTION) + .findOne({ _id: workspace }); + + // Check that the Workspace was located, if not return default permissions + if (!workspaceResult) { + return DEFAULT_WORKSPACE_PERMISSIONS; + } + + // Get the permissions of the User, assuming they are a collaborator + const collaboratorResult = workspaceResult.collaborators.find( + (collaborator: Collaborator) => collaborator._id === _id, + ); + if (collaboratorResult) { + return collaboratorResult.permissions; + } else { + // In the case where the User is the owner, return all permissions enabled by default + return { + administration: { + edit: true, + invite: true, + }, + entities: { + create: true, + edit: true, + archive: true, + }, + projects: { + create: true, + edit: true, + archive: true, + }, + templates: { + create: true, + edit: true, + archive: true, + }, + }; + } + }; + + static setUserWorkspacePermissions = async ( + _id: string, + workspace: string, + permissions: Partial, + ): Promise => { + // Get the current User's Workspace permissions + const workspaceResult = await getDatabase() + .collection(WORKSPACES_COLLECTION) + .findOne({ _id: workspace }); + if (!workspaceResult) { + return { + success: false, + message: "Unable to locate Workspace", + }; + } + + const collaboratorResult = workspaceResult.collaborators.find( + (collaborator: Collaborator) => collaborator._id === _id, + ); + if (!collaboratorResult) { + return { + success: false, + message: "Unable to locate Collaborator within Workspace", + }; + } + + // Create copy of permissions to update and copy any specified changes + const updatedPermissions = _.cloneDeep(collaboratorResult.permissions); + if (permissions?.administration?.edit !== undefined) + updatedPermissions.administration.edit = permissions.administration.edit; + if (permissions?.administration?.invite !== undefined) + updatedPermissions.administration.invite = permissions.administration.invite; + if (permissions?.entities?.create !== undefined) updatedPermissions.entities.create = permissions.entities.create; + if (permissions?.entities?.edit !== undefined) updatedPermissions.entities.edit = permissions.entities.edit; + if (permissions?.entities?.archive !== undefined) + updatedPermissions.entities.archive = permissions.entities.archive; + if (permissions?.projects?.create !== undefined) updatedPermissions.projects.create = permissions.projects.create; + if (permissions?.projects?.edit !== undefined) updatedPermissions.projects.edit = permissions.projects.edit; + if (permissions?.projects?.archive !== undefined) + updatedPermissions.projects.archive = permissions.projects.archive; + if (permissions?.templates?.create !== undefined) + updatedPermissions.templates.create = permissions.templates.create; + if (permissions?.templates?.edit !== undefined) updatedPermissions.templates.edit = permissions.templates.edit; + if (permissions?.templates?.archive !== undefined) + updatedPermissions.templates.archive = permissions.templates.archive; + + // Apply update in-place in list of Collaborators + for (const collaborator of workspaceResult.collaborators) { + if (collaborator._id === _id) { + collaborator.permissions = _.cloneDeep(updatedPermissions); + break; + } + } + + // Create and apply updated Collaborators + const update: Record = { + $set: { + collaborators: workspaceResult.collaborators, + }, + }; - const result = await getDatabase().collection(USERS_COLLECTION).updateOne({ _id: _id }, { $set: update }); + const result = await getDatabase() + .collection(WORKSPACES_COLLECTION) + .updateOne({ _id: workspace }, update); return { success: result.modifiedCount === 1, - message: result.modifiedCount === 1 ? "User features updated" : "Unable to update user features", + message: + result.modifiedCount === 1 + ? "User Workspace permissions updated" + : "Unable to update User Workspace permissions", }; }; + static getUserCollatedPermissions = async (_id: string, workspace: string): Promise => { + const userResult = await getDatabase().collection(USERS_COLLECTION).findOne({ _id: _id }); + const workspaceResult = await getDatabase() + .collection(WORKSPACES_COLLECTION) + .findOne({ _id: workspace }); + + // Check that both the User and Workspace were located, if not return default permissions + if (!userResult || !workspaceResult) { + return { + workspace: DEFAULT_WORKSPACE_PERMISSIONS, + global: DEFAULT_GLOBAL_PERMISSIONS, + }; + } + + const globalPermissions = parseGlobalPermissions(userResult.permissions, DEFAULT_GLOBAL_PERMISSIONS); + + // Check if User is Workspace owner or Collaborator + if (workspaceResult.owner === _id) { + // If owner, all permissions granted + return { + workspace: { + administration: { + edit: true, + invite: true, + }, + entities: { + create: true, + edit: true, + archive: true, + }, + projects: { + create: true, + edit: true, + archive: true, + }, + templates: { + create: true, + edit: true, + archive: true, + }, + }, + global: globalPermissions, + }; + } else { + const workspacePermissions = workspaceResult.collaborators.filter((collaborator: Collaborator) => { + return collaborator._id === _id; + }); + + if (workspacePermissions.length !== 1) { + return { + workspace: DEFAULT_WORKSPACE_PERMISSIONS, // Replace with default permissions + global: globalPermissions, + }; + } + + return { + workspace: workspacePermissions[0].permissions, + global: globalPermissions, + }; + } + }; + static setBanStatus = async (_id: string, banned: boolean): Promise => { - const result = await getDatabase().collection(USERS_COLLECTION).updateOne({ _id: _id }, { $set: { banned } }); + const result = await getDatabase() + .collection(USERS_COLLECTION) + .updateOne({ _id: _id }, { $set: { banned } }); return { success: result.modifiedCount === 1, @@ -119,7 +373,9 @@ export class Admin { }; static setUserRole = async (_id: string, role: string): Promise => { - const result = await getDatabase().collection(USERS_COLLECTION).updateOne({ _id: _id }, { $set: { role } }); + const result = await getDatabase() + .collection(USERS_COLLECTION) + .updateOne({ _id: _id }, { $set: { role } }); return { success: result.modifiedCount === 1, diff --git a/server/src/models/Projects.ts b/server/src/models/Projects.ts index 8b3dc72d..ca3a9180 100644 --- a/server/src/models/Projects.ts +++ b/server/src/models/Projects.ts @@ -44,6 +44,20 @@ export class Projects { return !_.isNull(project); }; + /** + * Get all Entities present in a Project + * @param _id Project identifier + * @return {Promise} + */ + static getEntities = async (_id: string): Promise => { + const project = await Projects.getOne(_id); + if (!_.isNull(project)) { + return await Entities.getMany(project.entities); + } else { + return []; + } + }; + static create = async (project: IProject): Promise> => { // Create a `ProjectModel` instance by adding an identifier and unpacking given Project data const projectModel: ProjectModel = { @@ -94,11 +108,6 @@ export class Projects { update.$set.description = updated.description; } - // Collaborators - if (!_.isUndefined(updated.collaborators)) { - update.$set.collaborators = updated.collaborators; - } - // Entities to add and remove if (!_.isUndefined(updated.entities)) { const toAdd = _.difference(updated.entities, project.entities); @@ -152,7 +161,6 @@ export class Projects { _id: historyProject._id, name: historyProject.name, owner: historyProject.owner, - collaborators: historyProject.collaborators, archived: historyProject.archived, created: historyProject.created, description: historyProject.description, 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 4514a66f..305e1842 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,37 +47,6 @@ 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; - }, - setBanStatus: async ( _parent: IResolverParent, args: { _id: string; banned: boolean }, diff --git a/server/src/resolvers/Projects.ts b/server/src/resolvers/Projects.ts index 00abf9b7..c1d970b1 100644 --- a/server/src/resolvers/Projects.ts +++ b/server/src/resolvers/Projects.ts @@ -84,6 +84,39 @@ export const ProjectsResolvers = { } }, + // Retrieve all Entities within a single Project + projectEntities: async (_parent: IResolverParent, args: { _id: string }, context: Context) => { + // Retrieve the Workspace to determine which Entities to return + const workspace = await Workspaces.getOne(context.workspace); + if (_.isNull(workspace)) { + throw new GraphQLError("Workspace does not exist", { + extensions: { + code: "NON_EXIST", + }, + }); + } + + // Check that Project exists + const project = await Projects.getOne(args._id); + if (_.isNull(project)) { + throw new GraphQLError("Project does not exist", { + extensions: { + code: "NON_EXIST", + }, + }); + } + + if (_.includes(workspace.projects, project._id)) { + return await Projects.getEntities(args._id); + } else { + throw new GraphQLError("You do not have permission to access this Project", { + extensions: { + code: "UNAUTHORIZED", + }, + }); + } + }, + exportProject: async ( _parent: IResolverParent, args: { _id: string; format: "json" | "csv"; fields?: string[]; includeHistory?: boolean }, diff --git a/server/src/resolvers/User.ts b/server/src/resolvers/User.ts index 4cd6da1e..486d87be 100644 --- a/server/src/resolvers/User.ts +++ b/server/src/resolvers/User.ts @@ -1,6 +1,16 @@ -import { IResolverParent, IResponseMessage, ResponseData, UserModel } from "@types"; +import { + Context, + IResolverParent, + IResponseMessage, + ResponseData, + UserCollatedPermissions, + UserGlobalPermissions, + UserModel, + UserWorkspacePermissions, +} from "@types"; // Models +import { Admin } from "@models/Admin"; import { User } from "@models/User"; // Email @@ -27,6 +37,30 @@ export const UserResolvers = { userByOrcid: async (_parent: IResolverParent, args: { orcid: string }): Promise> => { return await User.getByOrcid(args.orcid); }, + + userGlobalPermissions: async ( + _parent: IResolverParent, + args: { _id?: string }, + context: Context, + ): Promise => { + return await Admin.getUserGlobalPermissions(args._id ?? context.user); + }, + + userWorkspacePermissions: async ( + _parent: IResolverParent, + args: { _id?: string; workspace?: string }, + context: Context, + ): Promise => { + return await Admin.getUserWorkspacePermissions(args._id ?? context.user, args.workspace ?? context.workspace); + }, + + userCollatedPermissions: async ( + _parent: IResolverParent, + _args: Record, + context: Context, + ): Promise => { + return await Admin.getUserCollatedPermissions(context.user, context.workspace); + }, }, Mutation: { // Create a User @@ -39,6 +73,23 @@ export const UserResolvers = { return await User.update(args.user); }, + // Update a User's global permissions + setUserGlobalPermissions: async ( + _parent: IResolverParent, + args: { _id: string; permissions: UserGlobalPermissions }, + ): Promise => { + return await Admin.setUserGlobalPermissions(args._id, args.permissions); + }, + + // Update a User's global permissions + setUserWorkspacePermissions: async ( + _parent: IResolverParent, + args: { _id: string; permissions: UserWorkspacePermissions }, + context: Context, + ): Promise => { + return await Admin.setUserWorkspacePermissions(args._id, context.workspace, 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 c3a8428f..767e1dad 100644 --- a/server/src/resolvers/Workspaces.ts +++ b/server/src/resolvers/Workspaces.ts @@ -19,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 @@ -33,7 +36,7 @@ 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) || isCollaborator(context.user, workspace.collaborators); }); } @@ -59,7 +62,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, 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; @@ -90,7 +93,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, 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); @@ -122,7 +125,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, 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); @@ -154,7 +157,7 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, 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); @@ -220,23 +223,27 @@ export const WorkspacesResolvers = { // Access control if ( workspace && - (_.includes(workspace.collaborators, 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); // Notify any newly added collaborators - const newCollaborators = _.difference(args.workspace.collaborators, workspace.collaborators); + const newCollaborators = _.differenceBy(args.workspace.collaborators, workspace.collaborators, "_id"); 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 8267b447..698fb8f5 100644 --- a/server/src/typedefs.ts +++ b/server/src/typedefs.ts @@ -60,18 +60,129 @@ export const typedefs = `#graphql projects: Int templates: Int } - - # "UserFeatures" type - type UserFeatures { + + # All "Global" permissions ("features" and "workspaces") + # "UserFeaturesPermissions" type + type UserFeaturesPermissions { + import: Boolean + scan: Boolean ai: Boolean api: Boolean } - # "UserFeaturesInput" type - input UserFeaturesInput { + # "UserFeaturesPermissionsInput" type + input UserFeaturesPermissionsInput { + import: Boolean + scan: Boolean ai: Boolean api: Boolean } + + # "UserWorkspacesPermissions" type + type UserWorkspacesPermissions { + create: Boolean + } + + # "UserWorkspacesPermissionsInput" type + input UserWorkspacesPermissionsInput { + create: Boolean + } + + # "UserGlobalPermissions" type + type UserGlobalPermissions { + features: UserFeaturesPermissions + workspaces: UserWorkspacesPermissions + } + + # "UserGlobalPermissionsInput" type + input UserGlobalPermissionsInput { + features: UserFeaturesPermissionsInput + workspaces: UserWorkspacesPermissionsInput + } + + # All "Workspace" permissions ("administration", "entities", "projects", "templates") + # "UserWorkspaceAdministrationPermissions" type + type UserWorkspaceAdministrationPermissions { + edit: Boolean + invite: Boolean + } + + # "UserWorkspaceAdministrationPermissionsInput" type + input UserWorkspaceAdministrationPermissionsInput { + 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 { + administration: UserWorkspaceAdministrationPermissions + entities: UserWorkspaceEntitiesPermissions + projects: UserWorkspaceProjectsPermissions + templates: UserWorkspaceTemplatesPermissions + } + + # "UserWorkspacePermissionsInput" type + input UserWorkspacePermissionsInput { + administration: UserWorkspaceAdministrationPermissionsInput + entities: UserWorkspaceEntitiesPermissionsInput + projects: UserWorkspaceProjectsPermissionsInput + templates: UserWorkspaceTemplatesPermissionsInput + } + + # Collated permissions + # "UserCollatedPermissions" type + type UserCollatedPermissions { + workspace: UserWorkspacePermissions + global: UserGlobalPermissions + } + + # "UserCollatedPermissionsInput" type + input UserCollatedPermissionsInput { + workspace: UserWorkspacePermissionsInput + global: UserGlobalPermissionsInput + } # "AdminWorkspace" type type AdminWorkspace { @@ -80,8 +191,8 @@ export const typedefs = `#graphql description: String owner: String entities: Int + projects: Int templates: Int - attributes: Int } # "AdminUser" type @@ -91,7 +202,7 @@ export const typedefs = `#graphql email: String role: String workspaces: Int - features: UserFeatures + permissions: UserGlobalPermissions banned: Boolean lastLogin: String } @@ -122,7 +233,6 @@ export const typedefs = `#graphql description: String timestamp: String owner: String - collaborators: [String] created: String entities: [String] history: [ProjectHistory] @@ -138,7 +248,6 @@ export const typedefs = `#graphql _id: String! name: String owner: String - collaborators: [String] archived: Boolean created: String description: String @@ -153,7 +262,6 @@ export const typedefs = `#graphql owner: String! created: String! entities: [String]! - collaborators: [String]! } # "ProjectUpdateInput" type @@ -163,7 +271,6 @@ export const typedefs = `#graphql archived: Boolean description: String owner: String - collaborators: [String] created: String entities: [String] } @@ -392,6 +499,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 { @@ -401,7 +520,7 @@ export const typedefs = `#graphql public: Boolean description: String owner: String - collaborators: [String] + collaborators: [Collaborator] entities: [String] projects: [String] templates: [String] @@ -414,7 +533,7 @@ export const typedefs = `#graphql description: String public: Boolean owner: String - collaborators: [String] + collaborators: [CollaboratorInput] entities: [String] projects: [String] templates: [String] @@ -428,7 +547,7 @@ export const typedefs = `#graphql public: Boolean description: String owner: String - collaborators: [String] + collaborators: [CollaboratorInput] entities: [String] projects: [String] templates: [String] @@ -593,17 +712,20 @@ 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 + userGlobalPermissions(_id: String): UserGlobalPermissions + userWorkspacePermissions(_id: String, workspace: String): UserWorkspacePermissions + userCollatedPermissions: UserCollatedPermissions # Project queries projects(limit: Int, archived: Boolean): [Project] project(_id: String): Project + projectEntities(_id: String): [Entity] projectMetrics: ProjectMetrics # Entity queries @@ -684,7 +806,8 @@ export const typedefs = `#graphql # Admin mutations setUserRole(_id: String, role: String): ResponseMessage - setUserFeatures(_id: String, features: UserFeaturesInput): ResponseMessage + setUserGlobalPermissions(_id: String, permissions: UserGlobalPermissionsInput): ResponseMessage + setUserWorkspacePermissions(_id: String, workspace: String permissions: UserWorkspacePermissionsInput): ResponseMessage setBanStatus(_id: String, banned: Boolean): ResponseMessage # User mutations 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 ({ + _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).toBeTruthy(); + }); + + 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: [], }); diff --git a/templates/project.json b/templates/project.json index ef752873..8e98f104 100644 --- a/templates/project.json +++ b/templates/project.json @@ -5,9 +5,6 @@ "archived": false, "created": "2022-05-19T22:34:29.501Z", "timestamp": "2022-05-19T22:34:29.501Z", - "collaborators": [ - "" - ], "description": "Project description", "entities": [ "" diff --git a/types/index.d.ts b/types/index.d.ts index 83ad3b0d..e32454ed 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -130,13 +130,18 @@ 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[]; - setCollaborators: (value: React.SetStateAction) => void; + collaborators: Collaborator[]; + setCollaborators: (value: React.SetStateAction) => void; }; // "Linky" component props @@ -174,7 +179,6 @@ export type IProject = { owner: string; archived: boolean; created: string; - collaborators: string[]; description: string; entities: string[]; history: ProjectHistory[]; @@ -198,7 +202,6 @@ export type ProjectHistory = { name: string; description: string; entities: string[]; - collaborators: string[]; }; // Utility type used across other types, typically in a list @@ -260,7 +263,7 @@ export type IWorkspace = { owner: string; public: boolean; description: string; - collaborators: string[]; + collaborators: Collaborator[]; entities: string[]; projects: string[]; templates: string[]; @@ -731,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 @@ -742,6 +746,59 @@ export type UserModel = IUser & { _id: string; }; +// User Workspace permissions structure +export type UserWorkspacePermissions = { + administration: { + 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 = { + features: { + import: boolean; + scan: boolean; + ai: boolean; + api: boolean; + }; + workspaces: { + create: boolean; + }; +}; + +export type UserCollatedPermissions = { + workspace: UserWorkspacePermissions; + global: UserGlobalPermissions; +}; + +// Permissions Dialog props +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 + 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 export type IContentMetrics = { all: number; @@ -762,8 +819,8 @@ export type AdminWorkspace = { description: string; owner: string; entities: number; + projects: number; templates: number; - attributes: number; }; export type AdminMetrics = { @@ -774,18 +831,20 @@ 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; }; + +// 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; +};