mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add workspace sharing page (#20931)
resolves coder/internal#849 <img width="870" height="554" alt="Screenshot 2025-12-10 at 20 37 38" src="https://github.com/user-attachments/assets/8712bf81-dc6f-4645-9e32-65eee7882e76" />
This commit is contained in:
@@ -1925,6 +1925,16 @@ class ApiMethods {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getWorkspaceACL = async (
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<TypesGen.WorkspaceACL> => {
|
||||||
|
const response = await this.axios.get(
|
||||||
|
`/api/v2/workspaces/${workspaceId}/acl`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
updateWorkspaceACL = async (
|
updateWorkspaceACL = async (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: TypesGen.UpdateWorkspaceACL,
|
data: TypesGen.UpdateWorkspaceACL,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type {
|
|||||||
ProvisionerLogLevel,
|
ProvisionerLogLevel,
|
||||||
UsageAppName,
|
UsageAppName,
|
||||||
Workspace,
|
Workspace,
|
||||||
|
WorkspaceACL,
|
||||||
WorkspaceAgentLog,
|
WorkspaceAgentLog,
|
||||||
WorkspaceBuild,
|
WorkspaceBuild,
|
||||||
WorkspaceBuildParameter,
|
WorkspaceBuildParameter,
|
||||||
|
WorkspaceRole,
|
||||||
WorkspacesRequest,
|
WorkspacesRequest,
|
||||||
WorkspacesResponse,
|
WorkspacesResponse,
|
||||||
} from "api/typesGenerated";
|
} from "api/typesGenerated";
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
} from "modules/workspaces/permissions";
|
} from "modules/workspaces/permissions";
|
||||||
import type { ConnectionStatus } from "pages/TerminalPage/types";
|
import type { ConnectionStatus } from "pages/TerminalPage/types";
|
||||||
import type {
|
import type {
|
||||||
|
MutationOptions,
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryOptions,
|
QueryOptions,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
@@ -42,6 +45,63 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const workspaceACLKey = (workspaceId: string) => ["workspaceAcl", workspaceId];
|
||||||
|
|
||||||
|
export const workspaceACL = (workspaceId: string) => {
|
||||||
|
return {
|
||||||
|
queryKey: workspaceACLKey(workspaceId),
|
||||||
|
queryFn: () => API.getWorkspaceACL(workspaceId),
|
||||||
|
} satisfies QueryOptions<WorkspaceACL>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setWorkspaceUserRole = (
|
||||||
|
queryClient: QueryClient,
|
||||||
|
): MutationOptions<
|
||||||
|
void,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
role: WorkspaceRole;
|
||||||
|
}
|
||||||
|
> => {
|
||||||
|
return {
|
||||||
|
mutationFn: ({ workspaceId, userId, role }) =>
|
||||||
|
API.updateWorkspaceACL(workspaceId, {
|
||||||
|
user_roles: {
|
||||||
|
[userId]: role,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: async (_res, { workspaceId }) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: workspaceACLKey(workspaceId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setWorkspaceGroupRole = (
|
||||||
|
queryClient: QueryClient,
|
||||||
|
): MutationOptions<
|
||||||
|
void,
|
||||||
|
unknown,
|
||||||
|
{ workspaceId: string; groupId: string; role: WorkspaceRole }
|
||||||
|
> => {
|
||||||
|
return {
|
||||||
|
mutationFn: ({ workspaceId, groupId, role }) =>
|
||||||
|
API.updateWorkspaceACL(workspaceId, {
|
||||||
|
group_roles: {
|
||||||
|
[groupId]: role,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: async (_res, { workspaceId }) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: workspaceACLKey(workspaceId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & {
|
type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & {
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const SelectItem = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex items-center justify-center">
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator className="size-icon-sm">
|
<SelectPrimitive.ItemIndicator className="size-icon-sm">
|
||||||
<Check className="size-icon-sm" />
|
<Check className="size-icon-sm" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Group, ReducedUser, User } from "api/typesGenerated";
|
||||||
|
import { AvatarData } from "components/Avatar/AvatarData";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { getGroupSubtitle } from "utils/groups";
|
||||||
|
|
||||||
|
type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null;
|
||||||
|
|
||||||
|
type UserOption = User | ReducedUser;
|
||||||
|
type OptionType = UserOption | Group;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if the value is a Group.
|
||||||
|
* Groups have a "members" property that users don't have.
|
||||||
|
*/
|
||||||
|
export const isGroup = (
|
||||||
|
value: UserOrGroupAutocompleteValue,
|
||||||
|
): value is Group => {
|
||||||
|
return value !== null && typeof value === "object" && "members" in value;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserOrGroupOptionProps {
|
||||||
|
option: OptionType;
|
||||||
|
htmlProps: HTMLAttributes<HTMLLIElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserOrGroupOption = ({
|
||||||
|
option,
|
||||||
|
htmlProps,
|
||||||
|
}: UserOrGroupOptionProps) => {
|
||||||
|
const isOptionGroup = isGroup(option);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li {...htmlProps}>
|
||||||
|
<AvatarData
|
||||||
|
title={
|
||||||
|
isOptionGroup ? option.display_name || option.name : option.username
|
||||||
|
}
|
||||||
|
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
|
||||||
|
src={option.avatar_url}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
-59
@@ -1,17 +1,19 @@
|
|||||||
import { css } from "@emotion/react";
|
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { templaceACLAvailable } from "api/queries/templates";
|
import { templaceACLAvailable } from "api/queries/templates";
|
||||||
import type { Group, ReducedUser } from "api/typesGenerated";
|
import type { Group, ReducedUser } from "api/typesGenerated";
|
||||||
import { AvatarData } from "components/Avatar/AvatarData";
|
import {
|
||||||
|
isGroup,
|
||||||
|
UserOrGroupOption,
|
||||||
|
} from "components/UserOrGroupAutocomplete/UserOrGroupOption";
|
||||||
import { useDebouncedFunction } from "hooks/debounce";
|
import { useDebouncedFunction } from "hooks/debounce";
|
||||||
import { type ChangeEvent, type FC, useState } from "react";
|
import { type ChangeEvent, type FC, useState } from "react";
|
||||||
import { keepPreviousData, useQuery } from "react-query";
|
import { keepPreviousData, useQuery } from "react-query";
|
||||||
import { prepareQuery } from "utils/filters";
|
import { prepareQuery } from "utils/filters";
|
||||||
import { getGroupSubtitle } from "utils/groups";
|
|
||||||
|
|
||||||
export type UserOrGroupAutocompleteValue = ReducedUser | Group | null;
|
export type UserOrGroupAutocompleteValue = ReducedUser | Group | null;
|
||||||
|
type AutocompleteOption = Exclude<UserOrGroupAutocompleteValue, null>;
|
||||||
|
|
||||||
type UserOrGroupAutocompleteProps = {
|
type UserOrGroupAutocompleteProps = {
|
||||||
value: UserOrGroupAutocompleteValue;
|
value: UserOrGroupAutocompleteValue;
|
||||||
@@ -38,7 +40,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
|
|||||||
enabled: autoComplete.open,
|
enabled: autoComplete.open,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const options = aclAvailableQuery.data
|
const options: AutocompleteOption[] = aclAvailableQuery.data
|
||||||
? [
|
? [
|
||||||
...aclAvailableQuery.data.groups,
|
...aclAvailableQuery.data.groups,
|
||||||
...aclAvailableQuery.data.users,
|
...aclAvailableQuery.data.users,
|
||||||
@@ -81,68 +83,38 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
|
|||||||
onChange={(_, newValue) => {
|
onChange={(_, newValue) => {
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
isOptionEqualToValue={(option, optionValue) =>
|
||||||
|
option.id === optionValue.id
|
||||||
|
}
|
||||||
getOptionLabel={(option) =>
|
getOptionLabel={(option) =>
|
||||||
isGroup(option) ? option.display_name || option.name : option.email
|
isGroup(option) ? option.display_name || option.name : option.email
|
||||||
}
|
}
|
||||||
renderOption={(props, option) => {
|
renderOption={({ key, ...props }, option) => (
|
||||||
const isOptionGroup = isGroup(option);
|
<UserOrGroupOption key={key} htmlProps={props} option={option} />
|
||||||
|
)}
|
||||||
return (
|
|
||||||
<li {...props}>
|
|
||||||
<AvatarData
|
|
||||||
title={
|
|
||||||
isOptionGroup
|
|
||||||
? option.display_name || option.name
|
|
||||||
: option.username
|
|
||||||
}
|
|
||||||
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
|
|
||||||
src={option.avatar_url}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
options={options}
|
options={options}
|
||||||
loading={aclAvailableQuery.isFetching}
|
loading={aclAvailableQuery.isFetching}
|
||||||
css={autoCompleteStyles}
|
className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<>
|
<TextField
|
||||||
<TextField
|
{...params}
|
||||||
{...params}
|
margin="none"
|
||||||
margin="none"
|
size="small"
|
||||||
size="small"
|
placeholder="Search for user or group"
|
||||||
placeholder="Search for user or group"
|
InputProps={{
|
||||||
InputProps={{
|
...params.InputProps,
|
||||||
...params.InputProps,
|
onChange: handleFilterChange,
|
||||||
onChange: handleFilterChange,
|
endAdornment: (
|
||||||
endAdornment: (
|
<>
|
||||||
<>
|
{aclAvailableQuery.isFetching ? (
|
||||||
{aclAvailableQuery.isFetching ? (
|
<CircularProgress size={16} />
|
||||||
<CircularProgress size={16} />
|
) : null}
|
||||||
) : null}
|
{params.InputProps.endAdornment}
|
||||||
{params.InputProps.endAdornment}
|
</>
|
||||||
</>
|
),
|
||||||
),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => {
|
|
||||||
return value !== null && "members" in value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoCompleteStyles = css`
|
|
||||||
width: 300px;
|
|
||||||
|
|
||||||
& .MuiFormControl-root {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .MuiInputBase-root {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { groupsByOrganization } from "api/queries/groups";
|
||||||
|
import { users } from "api/queries/users";
|
||||||
|
import type { Group, User } from "api/typesGenerated";
|
||||||
|
import {
|
||||||
|
isGroup,
|
||||||
|
UserOrGroupOption,
|
||||||
|
} from "components/UserOrGroupAutocomplete/UserOrGroupOption";
|
||||||
|
import { useDebouncedFunction } from "hooks/debounce";
|
||||||
|
import { type ChangeEvent, type FC, useState } from "react";
|
||||||
|
import { keepPreviousData, useQuery } from "react-query";
|
||||||
|
import { prepareQuery } from "utils/filters";
|
||||||
|
|
||||||
|
type AutocompleteOption = User | Group;
|
||||||
|
export type UserOrGroupAutocompleteValue = AutocompleteOption | null;
|
||||||
|
|
||||||
|
type ExcludableOption = { id?: string | null } | null;
|
||||||
|
|
||||||
|
type UserOrGroupAutocompleteProps = {
|
||||||
|
value: UserOrGroupAutocompleteValue;
|
||||||
|
onChange: (value: UserOrGroupAutocompleteValue) => void;
|
||||||
|
organizationId: string;
|
||||||
|
exclude: ExcludableOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
organizationId,
|
||||||
|
exclude,
|
||||||
|
}) => {
|
||||||
|
const [autoComplete, setAutoComplete] = useState({
|
||||||
|
value: "",
|
||||||
|
open: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
...users({
|
||||||
|
q: prepareQuery(encodeURI(autoComplete.value)),
|
||||||
|
limit: 25,
|
||||||
|
}),
|
||||||
|
enabled: autoComplete.open,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
...groupsByOrganization(organizationId),
|
||||||
|
enabled: autoComplete.open,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterValue = autoComplete.value.trim().toLowerCase();
|
||||||
|
const groupOptions = groupsQuery.data
|
||||||
|
? groupsQuery.data.filter((group) => {
|
||||||
|
if (!filterValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const haystack = `${group.display_name ?? ""} ${group.name}`.trim();
|
||||||
|
return haystack.toLowerCase().includes(filterValue);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const excludeIds = exclude
|
||||||
|
.map((optionToExclude) => optionToExclude?.id)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
const options: AutocompleteOption[] = [
|
||||||
|
...groupOptions,
|
||||||
|
...(usersQuery.data?.users ?? []),
|
||||||
|
].filter((result) => !excludeIds.includes(result.id));
|
||||||
|
|
||||||
|
const { debounced: handleFilterChange } = useDebouncedFunction(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setAutoComplete((state) => ({
|
||||||
|
...state,
|
||||||
|
value: event.target.value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
noOptionsText="No users or groups found"
|
||||||
|
value={value}
|
||||||
|
id="workspace-user-or-group-autocomplete"
|
||||||
|
open={autoComplete.open}
|
||||||
|
onOpen={() => {
|
||||||
|
setAutoComplete((state) => ({
|
||||||
|
...state,
|
||||||
|
open: true,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setAutoComplete({
|
||||||
|
value: isGroup(value)
|
||||||
|
? value.display_name || value.name
|
||||||
|
: (value?.email ?? value?.username ?? ""),
|
||||||
|
open: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
onChange(newValue ?? null);
|
||||||
|
}}
|
||||||
|
isOptionEqualToValue={(option, optionValue) =>
|
||||||
|
option.id === optionValue.id
|
||||||
|
}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
isGroup(option) ? option.display_name || option.name : option.email
|
||||||
|
}
|
||||||
|
renderOption={({ key, ...props }, option) => (
|
||||||
|
<UserOrGroupOption key={key} htmlProps={props} option={option} />
|
||||||
|
)}
|
||||||
|
options={options}
|
||||||
|
loading={usersQuery.isFetching || groupsQuery.isFetching}
|
||||||
|
className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
margin="none"
|
||||||
|
size="small"
|
||||||
|
placeholder="Search for user or group"
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
onChange: handleFilterChange,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{(usersQuery.isFetching || groupsQuery.isFetching) && (
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
)}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+128
-6
@@ -1,19 +1,141 @@
|
|||||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
import { checkAuthorization } from "api/queries/authCheck";
|
||||||
|
import {
|
||||||
|
setWorkspaceGroupRole,
|
||||||
|
setWorkspaceUserRole,
|
||||||
|
workspaceACL,
|
||||||
|
} from "api/queries/workspaces";
|
||||||
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||||
|
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||||
|
import { Link } from "components/Link/Link";
|
||||||
|
import type { WorkspacePermissions } from "modules/workspaces/permissions";
|
||||||
|
import { workspaceChecks } from "modules/workspaces/permissions";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
|
import { docs } from "utils/docs";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
|
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
|
||||||
|
import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView";
|
||||||
|
|
||||||
const WorkspaceSharingPage: FC = () => {
|
const WorkspaceSharingPage: FC = () => {
|
||||||
const workspace = useWorkspaceSettings();
|
const workspace = useWorkspaceSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const workspaceACLQuery = useQuery(workspaceACL(workspace.id));
|
||||||
|
const checks = workspaceChecks(workspace);
|
||||||
|
const permissionsQuery = useQuery<WorkspacePermissions>({
|
||||||
|
...checkAuthorization({ checks }),
|
||||||
|
});
|
||||||
|
const permissions = permissionsQuery.data;
|
||||||
|
|
||||||
|
const addUserMutation = useMutation(setWorkspaceUserRole(queryClient));
|
||||||
|
const updateUserMutation = useMutation(setWorkspaceUserRole(queryClient));
|
||||||
|
const removeUserMutation = useMutation(setWorkspaceUserRole(queryClient));
|
||||||
|
|
||||||
|
const addGroupMutation = useMutation(setWorkspaceGroupRole(queryClient));
|
||||||
|
const updateGroupMutation = useMutation(setWorkspaceGroupRole(queryClient));
|
||||||
|
const removeGroupMutation = useMutation(setWorkspaceGroupRole(queryClient));
|
||||||
|
|
||||||
|
const canUpdatePermissions = Boolean(permissions?.updateWorkspace);
|
||||||
|
|
||||||
|
const mutationError =
|
||||||
|
addUserMutation.error ??
|
||||||
|
updateUserMutation.error ??
|
||||||
|
removeUserMutation.error ??
|
||||||
|
addGroupMutation.error ??
|
||||||
|
updateGroupMutation.error ??
|
||||||
|
removeGroupMutation.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-12 max-w-screen-md">
|
||||||
<title>{pageTitle(workspace.name, "Sharing")}</title>
|
<title>{pageTitle(workspace.name, "Sharing")}</title>
|
||||||
|
|
||||||
<PageHeader className="pt-0">
|
<header className="flex flex-col">
|
||||||
<PageHeaderTitle>Sharing</PageHeaderTitle>
|
<div className="flex flex-col gap-2">
|
||||||
</PageHeader>
|
<h1 className="text-3xl m-0">Workspace sharing</h1>
|
||||||
</>
|
<p className="flex flex-row gap-1 text-sm text-content-secondary font-medium m-0">
|
||||||
|
Workspace sharing allows you to share workspaces with other users
|
||||||
|
and groups.{" "}
|
||||||
|
<Link href={docs("/user-guides/shared-workspaces")}>View docs</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{workspaceACLQuery.isError && (
|
||||||
|
<ErrorAlert error={workspaceACLQuery.error} />
|
||||||
|
)}
|
||||||
|
{permissionsQuery.isError && (
|
||||||
|
<ErrorAlert error={permissionsQuery.error} />
|
||||||
|
)}
|
||||||
|
{Boolean(mutationError) && <ErrorAlert error={mutationError} />}
|
||||||
|
|
||||||
|
<WorkspaceSharingPageView
|
||||||
|
workspace={workspace}
|
||||||
|
workspaceACL={workspaceACLQuery.data}
|
||||||
|
canUpdatePermissions={canUpdatePermissions}
|
||||||
|
onAddUser={async (user, role, reset) => {
|
||||||
|
await addUserMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
displaySuccess("User added to workspace successfully!");
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
isAddingUser={addUserMutation.isPending}
|
||||||
|
onUpdateUser={async (user, role) => {
|
||||||
|
await updateUserMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
displaySuccess("User role updated successfully!");
|
||||||
|
}}
|
||||||
|
updatingUserId={
|
||||||
|
updateUserMutation.isPending
|
||||||
|
? updateUserMutation.variables?.userId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onRemoveUser={async (user) => {
|
||||||
|
await removeUserMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: "",
|
||||||
|
});
|
||||||
|
displaySuccess("User removed successfully!");
|
||||||
|
}}
|
||||||
|
onAddGroup={async (group, role, reset) => {
|
||||||
|
await addGroupMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
groupId: group.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
displaySuccess("Group added to workspace successfully!");
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
isAddingGroup={addGroupMutation.isPending}
|
||||||
|
onUpdateGroup={async (group, role) => {
|
||||||
|
await updateGroupMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
groupId: group.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
displaySuccess("Group role updated successfully!");
|
||||||
|
}}
|
||||||
|
updatingGroupId={
|
||||||
|
updateGroupMutation.isPending
|
||||||
|
? updateGroupMutation.variables?.groupId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onRemoveGroup={async (group) => {
|
||||||
|
await removeGroupMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
groupId: group.id,
|
||||||
|
role: "",
|
||||||
|
});
|
||||||
|
displaySuccess("Group removed successfully!");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
import type {
|
||||||
|
Group,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceACL,
|
||||||
|
WorkspaceGroup,
|
||||||
|
WorkspaceRole,
|
||||||
|
WorkspaceUser,
|
||||||
|
} from "api/typesGenerated";
|
||||||
|
import { Avatar } from "components/Avatar/Avatar";
|
||||||
|
import { AvatarData } from "components/Avatar/AvatarData";
|
||||||
|
import { Button } from "components/Button/Button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "components/DropdownMenu/DropdownMenu";
|
||||||
|
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "components/Select/Select";
|
||||||
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "components/Table/Table";
|
||||||
|
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||||
|
import { EllipsisVertical, UserPlusIcon } from "lucide-react";
|
||||||
|
import { type FC, useState } from "react";
|
||||||
|
import { getGroupSubtitle } from "utils/groups";
|
||||||
|
import {
|
||||||
|
UserOrGroupAutocomplete,
|
||||||
|
type UserOrGroupAutocompleteValue,
|
||||||
|
} from "./UserOrGroupAutocomplete";
|
||||||
|
|
||||||
|
type AddWorkspaceUserOrGroupProps = {
|
||||||
|
organizationID: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
workspaceACL: WorkspaceACL | undefined;
|
||||||
|
onSubmit: (
|
||||||
|
value: WorkspaceUser | Group | ({ role: WorkspaceRole } & User),
|
||||||
|
role: WorkspaceRole,
|
||||||
|
reset: () => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddWorkspaceUserOrGroup: FC<AddWorkspaceUserOrGroupProps> = ({
|
||||||
|
organizationID,
|
||||||
|
isLoading,
|
||||||
|
workspaceACL,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [selectedOption, setSelectedOption] =
|
||||||
|
useState<UserOrGroupAutocompleteValue>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<WorkspaceRole>("use");
|
||||||
|
const excludeFromAutocomplete = workspaceACL
|
||||||
|
? [...workspaceACL.group, ...workspaceACL.users]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const resetValues = () => {
|
||||||
|
setSelectedOption(null);
|
||||||
|
setSelectedRole("use");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (selectedOption && selectedRole) {
|
||||||
|
onSubmit(
|
||||||
|
{
|
||||||
|
...selectedOption,
|
||||||
|
role: selectedRole,
|
||||||
|
},
|
||||||
|
selectedRole,
|
||||||
|
resetValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<UserOrGroupAutocomplete
|
||||||
|
organizationId={organizationID}
|
||||||
|
value={selectedOption}
|
||||||
|
exclude={excludeFromAutocomplete}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
setSelectedOption(newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedRole}
|
||||||
|
onValueChange={(value: WorkspaceRole) => setSelectedRole(value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="use">Use</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!selectedRole || !selectedOption || isLoading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<Spinner loading={isLoading}>
|
||||||
|
<UserPlusIcon className="size-icon-sm" />
|
||||||
|
</Spinner>
|
||||||
|
Add member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoleSelectProps {
|
||||||
|
value: WorkspaceRole;
|
||||||
|
disabled?: boolean;
|
||||||
|
onValueChange: (value: WorkspaceRole) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RoleSelect: FC<RoleSelectProps> = ({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onValueChange,
|
||||||
|
}) => {
|
||||||
|
const roleLabels: Record<WorkspaceRole, string> = {
|
||||||
|
use: "Use",
|
||||||
|
admin: "Admin",
|
||||||
|
"": "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||||
|
<SelectTrigger className="w-40 h-auto">
|
||||||
|
<SelectValue>
|
||||||
|
<span className="bg-surface-secondary rounded-md px-3 py-0.5 inline-block">
|
||||||
|
{roleLabels[value]}
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="use" className="flex-col items-start py-2 w-64">
|
||||||
|
<div className="font-medium text-content-primary">Use</div>
|
||||||
|
<div className="text-xs text-content-secondary leading-snug mt-0.5">
|
||||||
|
Can read and access this workspace.
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="admin" className="flex-col items-start py-2 w-64">
|
||||||
|
<div className="font-medium text-content-primary">Admin</div>
|
||||||
|
<div className="text-xs text-content-secondary leading-snug mt-0.5">
|
||||||
|
Can manage workspace metadata, permissions, and settings.
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WorkspaceSharingPageViewProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
workspaceACL: WorkspaceACL | undefined;
|
||||||
|
canUpdatePermissions: boolean;
|
||||||
|
onAddUser: (
|
||||||
|
user: WorkspaceUser,
|
||||||
|
role: WorkspaceRole,
|
||||||
|
reset: () => void,
|
||||||
|
) => void;
|
||||||
|
isAddingUser: boolean;
|
||||||
|
onUpdateUser: (user: WorkspaceUser, role: WorkspaceRole) => void;
|
||||||
|
updatingUserId: WorkspaceUser["id"] | undefined;
|
||||||
|
onRemoveUser: (user: WorkspaceUser) => void;
|
||||||
|
onAddGroup: (group: Group, role: WorkspaceRole, reset: () => void) => void;
|
||||||
|
isAddingGroup: boolean;
|
||||||
|
onUpdateGroup: (group: WorkspaceGroup, role: WorkspaceRole) => void;
|
||||||
|
updatingGroupId?: WorkspaceGroup["id"] | undefined;
|
||||||
|
onRemoveGroup: (group: Group) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceSharingPageView: FC<WorkspaceSharingPageViewProps> = ({
|
||||||
|
workspace,
|
||||||
|
workspaceACL,
|
||||||
|
canUpdatePermissions,
|
||||||
|
onAddUser,
|
||||||
|
isAddingUser,
|
||||||
|
updatingUserId,
|
||||||
|
onUpdateUser,
|
||||||
|
onRemoveUser,
|
||||||
|
onAddGroup,
|
||||||
|
isAddingGroup,
|
||||||
|
updatingGroupId,
|
||||||
|
onUpdateGroup,
|
||||||
|
onRemoveGroup,
|
||||||
|
}) => {
|
||||||
|
const isEmpty = Boolean(
|
||||||
|
workspaceACL &&
|
||||||
|
workspaceACL.users.length === 0 &&
|
||||||
|
workspaceACL.group.length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{canUpdatePermissions && (
|
||||||
|
<AddWorkspaceUserOrGroup
|
||||||
|
organizationID={workspace.organization_id}
|
||||||
|
workspaceACL={workspaceACL}
|
||||||
|
isLoading={isAddingUser || isAddingGroup}
|
||||||
|
onSubmit={(value, role, resetAutocomplete) =>
|
||||||
|
"members" in value
|
||||||
|
? onAddGroup(value, role, resetAutocomplete)
|
||||||
|
: onAddUser(value, role, resetAutocomplete)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60%] py-2">Member</TableHead>
|
||||||
|
<TableHead className="w-[40%] py-2">Role</TableHead>
|
||||||
|
<TableHead className="w-[1%] py-2" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{!workspaceACL ? (
|
||||||
|
<TableLoader />
|
||||||
|
) : isEmpty ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={999}>
|
||||||
|
<EmptyState
|
||||||
|
message="No shared members or groups yet"
|
||||||
|
description="Add a member or group using the controls above"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{workspaceACL.group.map((group) => (
|
||||||
|
<TableRow key={group.id}>
|
||||||
|
<TableCell className="py-2">
|
||||||
|
<AvatarData
|
||||||
|
avatar={
|
||||||
|
<Avatar
|
||||||
|
size="lg"
|
||||||
|
fallback={group.display_name || group.name}
|
||||||
|
src={group.avatar_url}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={group.display_name || group.name}
|
||||||
|
subtitle={getGroupSubtitle(group)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-2">
|
||||||
|
{canUpdatePermissions ? (
|
||||||
|
<RoleSelect
|
||||||
|
value={group.role}
|
||||||
|
disabled={updatingGroupId === group.id}
|
||||||
|
onValueChange={(value) => onUpdateGroup(group, value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="capitalize">{group.role}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-2">
|
||||||
|
{canUpdatePermissions && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon-lg"
|
||||||
|
variant="subtle"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<EllipsisVertical aria-hidden="true" />
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-content-destructive focus:text-content-destructive"
|
||||||
|
onClick={() => onRemoveGroup(group)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{workspaceACL.users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="py-2">
|
||||||
|
<AvatarData
|
||||||
|
title={user.username}
|
||||||
|
subtitle={user.name}
|
||||||
|
src={user.avatar_url}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-2">
|
||||||
|
{canUpdatePermissions ? (
|
||||||
|
<RoleSelect
|
||||||
|
value={user.role}
|
||||||
|
disabled={updatingUserId === user.id}
|
||||||
|
onValueChange={(value) => onUpdateUser(user, value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="capitalize">{user.role}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-2">
|
||||||
|
{canUpdatePermissions && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon-lg"
|
||||||
|
variant="subtle"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<EllipsisVertical aria-hidden="true" />
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-content-destructive focus:text-content-destructive"
|
||||||
|
onClick={() => onRemoveUser(user)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user