Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
315a350
MARS-1190 Remove `Collaborators` from Projects
henryjburg Jul 14, 2026
be79de3
MARS-1190 Fix UI inconsistencies for Workspaces
henryjburg Jul 14, 2026
8675d38
MARS-1190 Update `Collaborators` component
henryjburg Jul 14, 2026
2ff350c
MARS-1190 Initial structure for RBAC
henryjburg Jul 16, 2026
b33c5f1
MARS-1190 Implement baseline global permissions
henryjburg Jul 16, 2026
80ee096
MARS-1190 Update to initial `global` permissions
henryjburg Jul 17, 2026
8ed6de9
MARS-1190 Fix confusing types
henryjburg Jul 17, 2026
c1e19b0
MARS-1190 Ability to set Entity permissions
henryjburg Jul 17, 2026
b69819b
MARS-1190 Enforce for Projects and Entities
henryjburg Jul 20, 2026
19a7937
MARS-1190 Extend to Templates
henryjburg Jul 20, 2026
932382f
MARS-1190 Update permissions views
henryjburg Jul 28, 2026
e5cab7e
MARS-1190 Update test helpers
henryjburg Jul 29, 2026
b1cd410
MARS-1190 Add server `admin` tests
henryjburg Jul 29, 2026
ed1c582
MARS-1190 Add new `permissions` client test suite
henryjburg Jul 29, 2026
c4b6d19
MARS-1190 Poll permissions updates
henryjburg Jul 29, 2026
684facd
MARS-1190 Defer updating permissions to saving
henryjburg Jul 29, 2026
a78bc74
MARS-1190 Fix UI bug when selecting menu item
henryjburg Jul 29, 2026
82802ba
MARS-1190 Add loading status to `usePermissions`
henryjburg Jul 29, 2026
026740f
MARS-1190 Update `Collaborators` view
henryjburg Jul 29, 2026
2311136
MARS-1190 Fix bugs identified with tests
henryjburg Jul 29, 2026
82da1eb
MARS-1190 Fix administration statistics
henryjburg Jul 29, 2026
87f0fb0
MARS-1190 Improve resilience of `usePermissions`
henryjburg Jul 29, 2026
c832f94
MARS-1190 Update `SearchBox` tests with variables
henryjburg Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -70,11 +70,11 @@ import { theme } from "./styles/theme";
const Providers = (): React.JSX.Element => {
return (
<ChakraProvider value={theme}>
<FeaturesProvider>
<WorkspaceProvider>
<WorkspaceProvider>
<PermissionsProvider>
<Outlet />
</WorkspaceProvider>
</FeaturesProvider>
</PermissionsProvider>
</WorkspaceProvider>
</ChakraProvider>
);
};
Expand Down
246 changes: 177 additions & 69 deletions client/src/components/Collaborators/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<UserModel> }>(GET_USER_EMAIL, {
variables: { _id: props.userId },
fetchPolicy: "network-only",
});

return (
<Link href={`mailto:${data?.user.email}`}>
<Text fontSize={"xs"} color={"gray.600"} ml={"0.5"}>
{loading ? "" : data?.user.email}
</Text>
</Link>
);
};

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<string>;
}>(GET_USER_BY_EMAIL, {
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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 (
Expand All @@ -122,34 +171,43 @@ const Collaborators = (props: CollaboratorsProps) => {
Collaborators ({props.collaborators.length})
</Text>
</Flex>
<Flex direction={"row"} gap={"2"} align={"center"} w={"100%"}>
<Fieldset.Root>
<Fieldset.Content>
<Field.Root invalid={newCollaborator !== "" && !validEmail}>
<Input
placeholder={"Email"}
size={"xs"}
rounded={"md"}
value={newCollaborator}
onChange={(event) => setNewCollaborator(event.target.value)}
disabled={!props.editing}
/>
</Field.Root>
</Fieldset.Content>
</Fieldset.Root>
<Button
colorPalette={"green"}
size={"xs"}
rounded={"md"}
disabled={!props.editing || !validEmail}
loading={addCollaboratorLoading || collaboratorQueryLoading}
loadingText={"Adding..."}
onClick={() => handleAddCollaborator()}
>
Add
<Icon name={"add"} size={"xs"} />
</Button>
</Flex>

{workspacePermissions.administration.invite && (
<Flex direction={"column"} gap={"1"}>
<Text fontSize={"xs"} ml={"0.5"} color={GLOBAL_STYLES.font.secondaryHeader.color}>
Invite Collaborators to this Workspace via email
</Text>
<Flex direction={"row"} gap={"2"} align={"center"} w={"100%"}>
<Fieldset.Root>
<Fieldset.Content>
<Field.Root invalid={newCollaborator !== "" && !validEmail}>
<Input
placeholder={"Email"}
size={"xs"}
rounded={"md"}
value={newCollaborator}
onChange={(event) => setNewCollaborator(event.target.value)}
disabled={!props.editing}
/>
</Field.Root>
</Fieldset.Content>
</Fieldset.Root>
<Button
colorPalette={"green"}
size={"xs"}
rounded={"md"}
disabled={!props.editing || !validEmail}
loading={addCollaboratorLoading || collaboratorQueryLoading}
loadingText={"Adding..."}
onClick={() => handleAddCollaborator()}
>
Invite
<Icon name={"add"} size={"xs"} />
</Button>
</Flex>
</Flex>
)}

<Flex
w={"100%"}
py={"1"}
Expand All @@ -168,49 +226,99 @@ const Collaborators = (props: CollaboratorsProps) => {
</EmptyState.Content>
</EmptyState.Root>
) : (
<Stack gap={"1"} separator={<Separator variant={"solid"} />} w={"100%"}>
<Stack gap={"2"} separator={<Separator variant={"solid"} />} w={"100%"}>
{props.collaborators.map((collaborator, index) => (
<Flex key={index} align={"center"} w={"100%"} justify={"space-between"}>
<Flex gap={"2"} align={"center"}>
<ActorTag identifier={collaborator} fallback={"New User"} size={"sm"} />
<Tag.Root colorPalette={"green"}>
<Tag.Label fontSize={"xs"}>Collaborator</Tag.Label>
</Tag.Root>
<Flex key={index} align={"start"} justify={"space-between"} direction={"row"} w={"100%"}>
<Flex direction={"column"} gap={"2"} align={"start"}>
<Text fontSize={"xs"} fontWeight={"semibold"} ml={"0.5"}>
Collaborator
</Text>
<ActorTag identifier={collaborator._id} fallback={"New User"} size={"sm"} />
<CollaboratorEmail userId={collaborator._id} />
</Flex>
{props.editing &&
(collaborator === props.currentUser && props.currentUser !== props.owner ? (
<Button
size={"2xs"}
colorPalette={"orange"}
rounded={"md"}
variant={"subtle"}
aria-label="Leave workspace"
onClick={() => handleRemoveCollaborator(collaborator)}
>
Leave
<Icon name="logout" size={"xs"} />
</Button>
) : (
collaborator !== props.owner && (

{/* Action Buttons, including Workspace remove / leave and permissions */}
<Flex direction={"column"} gap={"2"} align={"center"}>
{/* Permissions Labels */}
<Flex direction={"row"} gap={"1"} w={"100%"} align={"center"} justify={"end"} mr={"0.5"}>
{getCollaboratorPermissionsLevel(collaborator.permissions).map((label) => {
return (
<Tag.Root colorPalette={label.includes("Partial") ? "orange" : "green"}>
<Tag.Label fontSize={"xs"}>{label}</Tag.Label>
</Tag.Root>
);
})}
</Flex>

{/* Action Buttons */}
{props.editing && (
<Flex direction={"row"} gap={"2"} w={"100%"} justify={"end"} mr={"0.5"}>
{!isOwner && props.currentUser === collaborator._id && (
<Button
size={"xs"}
colorPalette={"orange"}
rounded={"md"}
variant={"solid"}
aria-label={"Leave workspace"}
onClick={() => handleRemoveCollaborator(collaborator._id)}
>
Leave Workspace
<Icon name={"logout"} size={"xs"} />
</Button>
)}

{isOwner && (
<Button
size={"xs"}
colorPalette={"red"}
rounded={"md"}
aria-label={"Remove collaborator"}
onClick={() => handleRemoveCollaborator(collaborator._id)}
>
Remove
<Icon name={"logout"} size={"xs"} />
</Button>
)}

<Button
size={"2xs"}
colorPalette={"red"}
size={"xs"}
colorPalette={"blue"}
rounded={"md"}
variant={"subtle"}
aria-label="Remove collaborator"
onClick={() => handleRemoveCollaborator(collaborator)}
variant={"solid"}
aria-label={isOwner ? "Manage permissions" : "View permissions"}
onClick={() => {
setPermissionsDialogUser(collaborator._id);
setPermissionsDialogOpen(true);
}}
>
Remove
<Icon name="delete" size={"xs"} />
{isOwner ? "Manage Permissions" : "View Permissions"}
<Icon name={"settings"} size={"xs"} />
</Button>
)
))}
</Flex>
)}
</Flex>
</Flex>
))}
</Stack>
)}
</Flex>
</Flex>

{permissionsDialogUser && (
<PermissionsDialog
open={permissionsDialogOpen}
setOpen={setPermissionsDialogOpen}
user={permissionsDialogUser}
isGlobal={false}
editable={isOwner}
workspacePermissions={getCollaboratorPermissions(permissionsDialogUser, props.collaborators)}
onUpdateWorkspacePermissions={(permissions) =>
props.setCollaborators((collaborators) =>
setCollaboratorPermissions(permissionsDialogUser, permissions, collaborators),
)
}
/>
)}
</Flex>
);
};
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Error/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const Error = ({ error }: ErrorProps) => {
<Flex direction={"column"} justify={"center"} align={"center"} h={"100%"} mt={{ base: "10%", lg: "0" }} p={"2"}>
<Flex
gap={"2"}
p={"2"}
p={"6"}
direction={"column"}
justify={"center"}
align={"center"}
Expand Down
8 changes: 5 additions & 3 deletions client/src/components/ImportDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { usePostHog } from "posthog-js/react";
import { ACCEPTED_IMPORTS_ENTITIES, ACCEPTED_IMPORTS_TEMPLATES, GLOBAL_STYLES } from "@variables";

// Hooks
import { useFeatures } from "@hooks/useFeatures";
import { usePermissions } from "@hooks/usePermissions";

// Variables
const JSON_MIME_TYPE = "application/json";
Expand Down Expand Up @@ -199,7 +199,9 @@ const IMPORT_TEMPLATE_JSON = gql`
const ImportDialog = (props: ImportDialogProps) => {
// Posthog
const posthog = usePostHog();
const { features } = useFeatures();

// Permissions
const { globalPermissions } = usePermissions();

// Operation and button states
const [importLoading, setImportLoading] = useState(false);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading