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 && (
);
-const userColumnHelper = createColumnHelper();
const workspaceColumnHelper = createColumnHelper();
const Admin = () => {
@@ -120,14 +118,15 @@ const Admin = () => {
adminWorkspaces: AdminWorkspace[];
}>(GET_ADMIN_DATA, { fetchPolicy: "network-only" });
- const [setUserFeatures] = useMutation<{ setUserFeatures: IResponseMessage }>(SET_USER_FEATURES, {
- onCompleted: () => refetch(),
- });
-
const [setBanStatus] = useMutation<{ setBanStatus: IResponseMessage }>(SET_BAN_STATUS, {
onCompleted: () => refetch(),
});
+ // `PermissionsDialog` state
+ const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
+ const [permissionsDialogUser, setPermissionsDialogUser] = useState("");
+
+ const userColumnHelper = createColumnHelper();
const usersTableColumns = [
userColumnHelper.accessor("email", {
cell: (info) => (
@@ -171,7 +170,7 @@ const Admin = () => {
}),
userColumnHelper.accessor("role", {
cell: (info) => (
-
+
{_.capitalize(info.getValue()) || "User"}
),
@@ -218,74 +217,52 @@ const Admin = () => {
),
- header: "Status",
- meta: { fixedWidth: 100 } as ColumnMeta,
+ header: "Account Status",
+ meta: { fixedWidth: 120 } as ColumnMeta,
}),
- userColumnHelper.display({
- id: "features",
+ userColumnHelper.accessor("_id", {
cell: (info) => (
-
-
- setUserFeatures({ variables: { _id: info.row.original._id, features: { ai: event.checked } } })
- }
- >
-
-
-
-
-
-
- AI features
-
-
-
-
- setUserFeatures({ variables: { _id: info.row.original._id, features: { api: event.checked } } })
- }
- >
-
-
-
-
-
-
- API access
-
-
+
),
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 ? "Save" : "Edit"}
- {editing ? : }
-
+
+ {editing ? "Save" : "Edit"}
+ {editing ? : }
+
+
)}
@@ -1125,17 +1232,27 @@ const Project = () => {
Preview
- handleRestoreFromHistoryClick(projectVersion)}
- disabled={projectArchived || !!previewVersion}
+
- Restore
-
-
+ handleRestoreFromHistoryClick(projectVersion)}
+ disabled={
+ projectArchived ||
+ !!previewVersion ||
+ !workspacePermissions.projects.archive
+ }
+ >
+ Restore
+
+
+
@@ -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}
- navigate("/create/project")} size={"xs"} rounded={"md"}>
- Create Project
-
-
+
+ navigate("/create/project")}
+ size={"xs"}
+ rounded={"md"}
+ disabled={!workspacePermissions.projects.create}
+ >
+ Create Project
+
+
+
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 = () => {
- {
- await handleRestoreFromHistoryClick(previewVersion);
- setPreviewVersion(null);
- }}
- disabled={templateArchived}
+
- Restore
-
-
+ {
+ await handleRestoreFromHistoryClick(previewVersion);
+ setPreviewVersion(null);
+ }}
+ disabled={templateArchived || !workspacePermissions.templates.archive}
+ >
+ Restore
+
+
+
{
Export
- setArchiveDialogOpen(true)}
- disabled={templateArchived}
+
-
- Archive
-
+ setArchiveDialogOpen(true)}
+ disabled={templateArchived || !workspacePermissions.templates.archive}
+ >
+
+ Archive
+
+
{templateArchived ? (
-
- Restore
-
-
+
+ Restore
+
+
+
) : (
{editing && (
@@ -688,19 +711,25 @@ const Template = () => {
)}
-
- {editing ? "Save" : "Edit"}
- {editing ? : }
-
+
+ {editing ? "Save" : "Edit"}
+ {editing ? : }
+
+
)}
@@ -990,17 +1019,27 @@ const Template = () => {
Preview
- handleRestoreFromHistoryClick(templateVersion)}
- disabled={templateArchived || !!previewVersion}
+
- Restore
-
-
+ handleRestoreFromHistoryClick(templateVersion)}
+ disabled={
+ templateArchived ||
+ !!previewVersion ||
+ !workspacePermissions.templates.archive
+ }
+ >
+ Restore
+
+
+
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}
- navigate("/create/template")} size={"xs"} rounded={"md"}>
- Create Template
-
-
+
+ navigate("/create/template")}
+ size={"xs"}
+ rounded={"md"}
+ disabled={!workspacePermissions.templates.create}
+ >
+ Create Template
+
+
+
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 = () => {
- archiveEntity(info.row.original._id, false)}
+
- Restore
- {}
-
+ archiveEntity(info.row.original._id, false)}
+ >
+ Restore
+ {}
+
+
{
- archiveProject(info.row.original._id, false)}
+
- Restore
- {}
-
+ archiveProject(info.row.original._id, false)}
+ >
+ Restore
+ {}
+
+
{
- archiveTemplate(info.row.original._id, false)}
+
- Restore
- {}
-
+ archiveTemplate(info.row.original._id, false)}
+ >
+ Restore
+ {}
+
+
{
-
- navigate("/")}>
- Cancel
-
-
- handleUpdateClick()}
- >
- Save
-
-
-
+ {workspacePermissions.administration.edit && (
+
+ navigate("/")}>
+ Cancel
+
+
+ handleUpdateClick()}
+ >
+ Save
+
+
+
+ )}
@@ -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;
+};