coder/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView...

416 lines
12 KiB
TypeScript

import MenuItem from "@mui/material/MenuItem";
import Select, { type SelectProps } from "@mui/material/Select";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import PersonAdd from "@mui/icons-material/PersonAdd";
import LoadingButton from "@mui/lab/LoadingButton";
import { type Interpolation, type Theme } from "@emotion/react";
import { type FC, useState } from "react";
import type {
Group,
ReducedUser,
TemplateACL,
TemplateGroup,
TemplateRole,
TemplateUser,
} from "api/typesGenerated";
import { getGroupSubtitle } from "utils/groups";
import { GroupAvatar } from "components/GroupAvatar/GroupAvatar";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { AvatarData } from "components/AvatarData/AvatarData";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Stack } from "components/Stack/Stack";
import { TableLoader } from "components/TableLoader/TableLoader";
import {
UserOrGroupAutocomplete,
UserOrGroupAutocompleteValue,
} from "./UserOrGroupAutocomplete";
type AddTemplateUserOrGroupProps = {
organizationId: string;
templateID: string;
isLoading: boolean;
templateACL: TemplateACL | undefined;
onSubmit: (
userOrGroup:
| TemplateUser
| TemplateGroup
// Reduce user is returned by the groups.
| ({ role: TemplateRole } & ReducedUser),
role: TemplateRole,
reset: () => void,
) => void;
};
const AddTemplateUserOrGroup: FC<AddTemplateUserOrGroupProps> = ({
isLoading,
onSubmit,
templateID,
templateACL,
}) => {
const [selectedOption, setSelectedOption] =
useState<UserOrGroupAutocompleteValue>(null);
const [selectedRole, setSelectedRole] = useState<TemplateRole>("use");
const excludeFromAutocomplete = templateACL
? [...templateACL.group, ...templateACL.users]
: [];
const resetValues = () => {
setSelectedOption(null);
setSelectedRole("use");
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (selectedOption && selectedRole) {
onSubmit(
{
...selectedOption,
role: selectedRole,
},
selectedRole,
resetValues,
);
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserOrGroupAutocomplete
exclude={excludeFromAutocomplete}
templateID={templateID}
value={selectedOption}
onChange={(newValue) => {
setSelectedOption(newValue);
}}
/>
<Select
defaultValue="use"
size="small"
css={styles.select}
disabled={isLoading}
onChange={(event) => {
setSelectedRole(event.target.value as TemplateRole);
}}
>
<MenuItem key="use" value="use">
Use
</MenuItem>
<MenuItem key="admin" value="admin">
Admin
</MenuItem>
</Select>
<LoadingButton
loadingPosition="start"
disabled={!selectedRole || !selectedOption}
type="submit"
startIcon={<PersonAdd />}
loading={isLoading}
>
Add member
</LoadingButton>
</Stack>
</form>
);
};
const RoleSelect: FC<SelectProps> = (props) => {
return (
<Select
renderValue={(value) => <div css={styles.role}>{`${value}`}</div>}
css={styles.updateSelect}
{...props}
>
<MenuItem key="use" value="use" css={styles.menuItem}>
<div>
<div>Use</div>
<div css={styles.menuItemSecondary}>
Can read and use this template to create workspaces.
</div>
</div>
</MenuItem>
<MenuItem key="admin" value="admin" css={styles.menuItem}>
<div>
<div>Admin</div>
<div css={styles.menuItemSecondary}>
Can modify all aspects of this template including permissions,
metadata, and template versions.
</div>
</div>
</MenuItem>
</Select>
);
};
export interface TemplatePermissionsPageViewProps {
templateACL: TemplateACL | undefined;
templateID: string;
organizationId: string;
canUpdatePermissions: boolean;
// User
onAddUser: (
user: TemplateUser | ({ role: TemplateRole } & ReducedUser),
role: TemplateRole,
reset: () => void,
) => void;
isAddingUser: boolean;
onUpdateUser: (user: TemplateUser, role: TemplateRole) => void;
updatingUserId: TemplateUser["id"] | undefined;
onRemoveUser: (user: TemplateUser) => void;
// Group
onAddGroup: (
group: TemplateGroup,
role: TemplateRole,
reset: () => void,
) => void;
isAddingGroup: boolean;
onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void;
updatingGroupId?: TemplateGroup["id"] | undefined;
onRemoveGroup: (group: Group) => void;
}
export const TemplatePermissionsPageView: FC<
TemplatePermissionsPageViewProps
> = ({
templateACL,
canUpdatePermissions,
organizationId,
templateID,
// User
onAddUser,
isAddingUser,
updatingUserId,
onUpdateUser,
onRemoveUser,
// Group
onAddGroup,
isAddingGroup,
updatingGroupId,
onUpdateGroup,
onRemoveGroup,
}) => {
const isEmpty = Boolean(
templateACL &&
templateACL.users.length === 0 &&
templateACL.group.length === 0,
);
return (
<>
<PageHeader css={styles.pageHeader}>
<PageHeaderTitle>Permissions</PageHeaderTitle>
</PageHeader>
<Stack spacing={2.5}>
{canUpdatePermissions && (
<AddTemplateUserOrGroup
templateACL={templateACL}
templateID={templateID}
organizationId={organizationId}
isLoading={isAddingUser || isAddingGroup}
onSubmit={(value, role, resetAutocomplete) =>
"members" in value
? onAddGroup(value, role, resetAutocomplete)
: onAddUser(value, role, resetAutocomplete)
}
/>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="60%">Member</TableCell>
<TableCell width="40%">Role</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={!templateACL}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{templateACL?.group.map((group) => (
<TableRow key={group.id}>
<TableCell>
<AvatarData
avatar={
<GroupAvatar
name={group.display_name || group.name}
avatarURL={group.avatar_url}
/>
}
title={group.display_name || group.name}
subtitle={getGroupSubtitle(group)}
/>
</TableCell>
<TableCell>
<ChooseOne>
<Cond condition={canUpdatePermissions}>
<RoleSelect
value={group.role}
disabled={updatingGroupId === group.id}
onChange={(event) => {
onUpdateGroup(
group,
event.target.value as TemplateRole,
);
}}
/>
</Cond>
<Cond>
<div css={styles.role}>{group.role}</div>
</Cond>
</ChooseOne>
</TableCell>
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
onClick={() => onRemoveGroup(group)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
))}
{templateACL?.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</TableCell>
<TableCell>
<ChooseOne>
<Cond condition={canUpdatePermissions}>
<RoleSelect
value={user.role}
disabled={updatingUserId === user.id}
onChange={(event) => {
onUpdateUser(
user,
event.target.value as TemplateRole,
);
}}
/>
</Cond>
<Cond>
<div css={styles.role}>{user.role}</div>
</Cond>
</ChooseOne>
</TableCell>
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
onClick={() => onRemoveUser(user)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
))}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Stack>
</>
);
};
const styles = {
select: {
// Match button small height
fontSize: 14,
width: 100,
},
updateSelect: {
margin: 0,
// Set a fixed width for the select. It avoids selects having different sizes
// depending on how many roles they have selected.
width: 200,
"& .MuiSelect-root": {
// Adjusting padding because it does not have label
paddingTop: 12,
paddingBottom: 12,
".secondary": {
display: "none",
},
},
},
role: {
textTransform: "capitalize",
},
menuItem: {
lineHeight: "140%",
paddingTop: 12,
paddingBottom: 12,
whiteSpace: "normal",
inlineSize: "250px",
},
menuItemSecondary: (theme) => ({
fontSize: 14,
color: theme.palette.text.secondary,
}),
pageHeader: {
paddingTop: 0,
},
} satisfies Record<string, Interpolation<Theme>>;