mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: create Workspace sharing form component using workspace sharing hook (#21276)
This PR separate the data retrieval for workspace sharing ACL into a custom hook and creates a separate form component. This is in preparation for reusing the workspace sharing form from a new share button on the workspace page.
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import type { Group, ReducedUser, User } from "api/typesGenerated";
|
||||
import type {
|
||||
Group,
|
||||
ReducedUser,
|
||||
User,
|
||||
WorkspaceUser,
|
||||
} from "api/typesGenerated";
|
||||
|
||||
type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null;
|
||||
/**
|
||||
* Union of all user-like types that can be distinguished from Group.
|
||||
*/
|
||||
type UserLike = User | ReducedUser | WorkspaceUser;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export const isGroup = (value: UserLike | Group): value is Group => {
|
||||
return "members" in value;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import type {
|
||||
Group,
|
||||
WorkspaceACL,
|
||||
WorkspaceGroup,
|
||||
WorkspaceRole,
|
||||
WorkspaceUser,
|
||||
} from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
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 { getGroupSubtitle } from "modules/groups";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
type AddWorkspaceMemberFormProps = {
|
||||
isLoading: boolean;
|
||||
onSubmit: () => void;
|
||||
disabled: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const AddWorkspaceMemberForm: FC<AddWorkspaceMemberFormProps> = ({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
disabled,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<form action={onSubmit}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{children}
|
||||
<Button disabled={disabled || isLoading} type="submit">
|
||||
<Spinner loading={isLoading}>
|
||||
<UserPlusIcon className="size-icon-sm" />
|
||||
</Spinner>
|
||||
Add member
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type RoleSelectFieldProps = {
|
||||
value: WorkspaceRole;
|
||||
onChange: (value: WorkspaceRole) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RoleSelectField: FC<RoleSelectFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val: WorkspaceRole) => onChange(val)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="use">Use</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
interface WorkspaceSharingFormProps {
|
||||
workspaceACL: WorkspaceACL | undefined;
|
||||
canUpdatePermissions: boolean;
|
||||
error: unknown;
|
||||
onUpdateUser: (user: WorkspaceUser, role: WorkspaceRole) => void;
|
||||
updatingUserId: WorkspaceUser["id"] | undefined;
|
||||
onRemoveUser: (user: WorkspaceUser) => void;
|
||||
onUpdateGroup: (group: WorkspaceGroup, role: WorkspaceRole) => void;
|
||||
updatingGroupId?: WorkspaceGroup["id"] | undefined;
|
||||
onRemoveGroup: (group: Group) => void;
|
||||
addMemberForm?: ReactNode;
|
||||
}
|
||||
|
||||
export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
|
||||
workspaceACL,
|
||||
canUpdatePermissions,
|
||||
error,
|
||||
updatingUserId,
|
||||
onUpdateUser,
|
||||
onRemoveUser,
|
||||
updatingGroupId,
|
||||
onUpdateGroup,
|
||||
onRemoveGroup,
|
||||
addMemberForm,
|
||||
}) => {
|
||||
const isEmpty = Boolean(
|
||||
workspaceACL &&
|
||||
workspaceACL.users.length === 0 &&
|
||||
workspaceACL.group.length === 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
{canUpdatePermissions && addMemberForm}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
setWorkspaceGroupRole,
|
||||
setWorkspaceUserRole,
|
||||
workspaceACL,
|
||||
} from "api/queries/workspaces";
|
||||
import type {
|
||||
Group,
|
||||
Workspace,
|
||||
WorkspaceGroup,
|
||||
WorkspaceRole,
|
||||
WorkspaceUser,
|
||||
} from "api/typesGenerated";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
|
||||
/**
|
||||
* Encapsulates all data fetching and mutations for workspace sharing.
|
||||
* This hook manages the workspace ACL query and provides methods to
|
||||
* add, update, and remove users and groups from the workspace.
|
||||
*/
|
||||
export function useWorkspaceSharing(workspace: Workspace) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const workspaceACLQuery = useQuery(workspaceACL(workspace.id));
|
||||
|
||||
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 addUser = async (
|
||||
user: WorkspaceUser,
|
||||
role: WorkspaceRole,
|
||||
reset: () => void,
|
||||
) => {
|
||||
await addUserMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role,
|
||||
});
|
||||
displaySuccess("User added to workspace successfully!");
|
||||
reset();
|
||||
};
|
||||
|
||||
const updateUser = async (user: WorkspaceUser, role: WorkspaceRole) => {
|
||||
await updateUserMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role,
|
||||
});
|
||||
displaySuccess("User role updated successfully!");
|
||||
};
|
||||
|
||||
const removeUser = async (user: WorkspaceUser) => {
|
||||
await removeUserMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role: "",
|
||||
});
|
||||
displaySuccess("User removed successfully!");
|
||||
};
|
||||
|
||||
const addGroup = async (
|
||||
group: Group,
|
||||
role: WorkspaceRole,
|
||||
reset: () => void,
|
||||
) => {
|
||||
await addGroupMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
groupId: group.id,
|
||||
role,
|
||||
});
|
||||
displaySuccess("Group added to workspace successfully!");
|
||||
reset();
|
||||
};
|
||||
|
||||
const updateGroup = async (group: WorkspaceGroup, role: WorkspaceRole) => {
|
||||
await updateGroupMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
groupId: group.id,
|
||||
role,
|
||||
});
|
||||
displaySuccess("Group role updated successfully!");
|
||||
};
|
||||
|
||||
const removeGroup = async (group: Group) => {
|
||||
await removeGroupMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
groupId: group.id,
|
||||
role: "",
|
||||
});
|
||||
displaySuccess("Group removed successfully!");
|
||||
};
|
||||
|
||||
const mutationError =
|
||||
addUserMutation.error ??
|
||||
updateUserMutation.error ??
|
||||
removeUserMutation.error ??
|
||||
addGroupMutation.error ??
|
||||
updateGroupMutation.error ??
|
||||
removeGroupMutation.error;
|
||||
|
||||
return {
|
||||
workspaceACL: workspaceACLQuery.data,
|
||||
isLoading: workspaceACLQuery.isLoading,
|
||||
error: workspaceACLQuery.error,
|
||||
mutationError,
|
||||
// User actions
|
||||
addUser,
|
||||
updateUser,
|
||||
removeUser,
|
||||
isAddingUser: addUserMutation.isPending,
|
||||
updatingUserId: updateUserMutation.isPending
|
||||
? updateUserMutation.variables?.userId
|
||||
: undefined,
|
||||
// Group actions
|
||||
addGroup,
|
||||
updateGroup,
|
||||
removeGroup,
|
||||
isAddingGroup: addGroupMutation.isPending,
|
||||
updatingGroupId: updateGroupMutation.isPending
|
||||
? updateGroupMutation.variables?.groupId
|
||||
: undefined,
|
||||
} as const;
|
||||
}
|
||||
+17
-97
@@ -1,16 +1,10 @@
|
||||
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 { useWorkspaceSharing } from "modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing";
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useQuery } from "react-query";
|
||||
import { docs } from "utils/docs";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
|
||||
@@ -18,32 +12,17 @@ import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView";
|
||||
|
||||
const WorkspaceSharingPage: FC = () => {
|
||||
const workspace = useWorkspaceSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const sharing = useWorkspaceSharing(workspace);
|
||||
|
||||
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;
|
||||
const error =
|
||||
sharing.error ?? permissionsQuery.error ?? sharing.mutationError;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 max-w-screen-md">
|
||||
@@ -60,80 +39,21 @@ const WorkspaceSharingPage: FC = () => {
|
||||
</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}
|
||||
workspaceACL={sharing.workspaceACL}
|
||||
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!");
|
||||
}}
|
||||
error={error}
|
||||
onAddUser={sharing.addUser}
|
||||
isAddingUser={sharing.isAddingUser}
|
||||
onUpdateUser={sharing.updateUser}
|
||||
updatingUserId={sharing.updatingUserId}
|
||||
onRemoveUser={sharing.removeUser}
|
||||
onAddGroup={sharing.addGroup}
|
||||
isAddingGroup={sharing.isAddingGroup}
|
||||
onUpdateGroup={sharing.updateGroup}
|
||||
updatingGroupId={sharing.updatingGroupId}
|
||||
onRemoveGroup={sharing.removeGroup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+39
-248
@@ -7,35 +7,12 @@ import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceUser,
|
||||
} from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { isGroup } from "modules/groups";
|
||||
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 { getGroupSubtitle } from "modules/groups";
|
||||
AddWorkspaceMemberForm,
|
||||
RoleSelectField,
|
||||
WorkspaceSharingForm,
|
||||
} from "modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
UserOrGroupAutocomplete,
|
||||
@@ -72,10 +49,10 @@ const AddWorkspaceUserOrGroup: FC<AddWorkspaceUserOrGroupProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
<AddWorkspaceMemberForm
|
||||
isLoading={isLoading}
|
||||
disabled={!selectedRole || !selectedOption}
|
||||
onSubmit={() => {
|
||||
if (selectedOption && selectedRole) {
|
||||
onSubmit(
|
||||
{
|
||||
@@ -88,85 +65,21 @@ const AddWorkspaceUserOrGroup: FC<AddWorkspaceUserOrGroupProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<UserOrGroupAutocomplete
|
||||
organizationId={organizationID}
|
||||
value={selectedOption}
|
||||
exclude={excludeFromAutocomplete}
|
||||
onChange={(newValue) => {
|
||||
setSelectedOption(newValue);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<RoleSelectField
|
||||
value={selectedRole}
|
||||
onChange={setSelectedRole}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</AddWorkspaceMemberForm>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -174,6 +87,7 @@ interface WorkspaceSharingPageViewProps {
|
||||
workspace: Workspace;
|
||||
workspaceACL: WorkspaceACL | undefined;
|
||||
canUpdatePermissions: boolean;
|
||||
error: unknown;
|
||||
onAddUser: (
|
||||
user: WorkspaceUser,
|
||||
role: WorkspaceRole,
|
||||
@@ -194,6 +108,7 @@ export const WorkspaceSharingPageView: FC<WorkspaceSharingPageViewProps> = ({
|
||||
workspace,
|
||||
workspaceACL,
|
||||
canUpdatePermissions,
|
||||
error,
|
||||
onAddUser,
|
||||
isAddingUser,
|
||||
updatingUserId,
|
||||
@@ -205,153 +120,29 @@ export const WorkspaceSharingPageView: FC<WorkspaceSharingPageViewProps> = ({
|
||||
onUpdateGroup,
|
||||
onRemoveGroup,
|
||||
}) => {
|
||||
const isEmpty = Boolean(
|
||||
workspaceACL &&
|
||||
workspaceACL.users.length === 0 &&
|
||||
workspaceACL.group.length === 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canUpdatePermissions && (
|
||||
<WorkspaceSharingForm
|
||||
workspaceACL={workspaceACL}
|
||||
canUpdatePermissions={canUpdatePermissions}
|
||||
error={error}
|
||||
updatingUserId={updatingUserId}
|
||||
onUpdateUser={onUpdateUser}
|
||||
onRemoveUser={onRemoveUser}
|
||||
updatingGroupId={updatingGroupId}
|
||||
onUpdateGroup={onUpdateGroup}
|
||||
onRemoveGroup={onRemoveGroup}
|
||||
addMemberForm={
|
||||
<AddWorkspaceUserOrGroup
|
||||
organizationID={workspace.organization_id}
|
||||
workspaceACL={workspaceACL}
|
||||
isLoading={isAddingUser || isAddingGroup}
|
||||
onSubmit={(value, role, resetAutocomplete) =>
|
||||
"members" in value
|
||||
isGroup(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