diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index dffbc5200c767..0b027f21ff2e0 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -2,6 +2,7 @@ package coderd import ( "database/sql" + "errors" "fmt" "net/http" @@ -170,9 +171,9 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { OrganizationID: group.OrganizationID, UserID: uuid.MustParse(id), })) - if xerrors.Is(err, sql.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID), + Message: fmt.Sprintf("User must be a member of organization %q", group.Name), }) return } @@ -364,7 +365,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { ) users, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) - if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return } @@ -391,7 +392,7 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { ) groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID) - if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 40627fe4720c2..b408e290e1273 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -515,19 +515,19 @@ class ApiMethods { }; updateOrganization = async ( - orgId: string, + organizationId: string, params: TypesGen.UpdateOrganizationRequest, ) => { const response = await this.axios.patch( - `/api/v2/organizations/${orgId}`, + `/api/v2/organizations/${organizationId}`, params, ); return response.data; }; - deleteOrganization = async (orgId: string) => { + deleteOrganization = async (organizationId: string) => { await this.axios.delete( - `/api/v2/organizations/${orgId}`, + `/api/v2/organizations/${organizationId}`, ); }; @@ -1485,9 +1485,12 @@ class ApiMethods { return response.data; }; - getGroup = async (groupName: string): Promise => { + getGroup = async ( + organizationId: string, + groupName: string, + ): Promise => { const response = await this.axios.get( - `/api/v2/organizations/default/groups/${groupName}`, + `/api/v2/organizations/${organizationId}/groups/${groupName}`, ); return response.data; }; diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index feeeb2335b16b..e532ebcd81d43 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -9,7 +9,11 @@ import type { const GROUPS_QUERY_KEY = ["groups"]; type GroupSortOrder = "asc" | "desc"; -const getGroupQueryKey = (groupName: string) => ["group", groupName]; +const getGroupQueryKey = (organizationId: string, groupName: string) => [ + organizationId, + "group", + groupName, +]; export const groups = (organizationId: string) => { return { @@ -18,10 +22,10 @@ export const groups = (organizationId: string) => { } satisfies UseQueryOptions; }; -export const group = (groupName: string) => { +export const group = (organizationId: string, groupName: string) => { return { - queryKey: getGroupQueryKey(groupName), - queryFn: () => API.getGroup(groupName), + queryKey: getGroupQueryKey(organizationId, groupName), + queryFn: () => API.getGroup(organizationId, groupName), }; }; @@ -69,7 +73,7 @@ export function groupsForUser(organizationId: string, userId: string) { export const groupPermissions = (groupId: string) => { return { - queryKey: [...getGroupQueryKey(groupId), "permissions"], + queryKey: ["group", groupId, "permissions"], queryFn: () => API.checkAuthorization({ checks: { @@ -85,12 +89,12 @@ export const groupPermissions = (groupId: string) => { }; }; -export const createGroup = (queryClient: QueryClient) => { +export const createGroup = ( + queryClient: QueryClient, + organizationId: string, +) => { return { - mutationFn: ({ - organizationId, - ...request - }: CreateGroupRequest & { organizationId: string }) => + mutationFn: (request: CreateGroupRequest) => API.createGroup(organizationId, request), onSuccess: async () => { await queryClient.invalidateQueries(GROUPS_QUERY_KEY); @@ -106,7 +110,7 @@ export const patchGroup = (queryClient: QueryClient) => { }: PatchGroupRequest & { groupId: string }) => API.patchGroup(groupId, request), onSuccess: async (updatedGroup: Group) => - invalidateGroup(queryClient, updatedGroup.id), + invalidateGroup(queryClient, "default", updatedGroup.id), }; }; @@ -114,7 +118,7 @@ export const deleteGroup = (queryClient: QueryClient) => { return { mutationFn: API.deleteGroup, onSuccess: async (_: void, groupId: string) => - invalidateGroup(queryClient, groupId), + invalidateGroup(queryClient, "default", groupId), }; }; @@ -123,7 +127,7 @@ export const addMember = (queryClient: QueryClient) => { mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => API.addMember(groupId, userId), onSuccess: async (updatedGroup: Group) => - invalidateGroup(queryClient, updatedGroup.id), + invalidateGroup(queryClient, "default", updatedGroup.id), }; }; @@ -132,14 +136,18 @@ export const removeMember = (queryClient: QueryClient) => { mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => API.removeMember(groupId, userId), onSuccess: async (updatedGroup: Group) => - invalidateGroup(queryClient, updatedGroup.id), + invalidateGroup(queryClient, "default", updatedGroup.id), }; }; -export const invalidateGroup = (queryClient: QueryClient, groupId: string) => +export const invalidateGroup = ( + queryClient: QueryClient, + organizationId: string, + groupId: string, +) => Promise.all([ queryClient.invalidateQueries(GROUPS_QUERY_KEY), - queryClient.invalidateQueries(getGroupQueryKey(groupId)), + queryClient.invalidateQueries(getGroupQueryKey(organizationId, groupId)), ]); export function sortGroupsByName( diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 171efaec104f4..1dc44a2a1c9a3 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -19,14 +19,14 @@ export const createOrganization = (queryClient: QueryClient) => { }; interface UpdateOrganizationVariables { - orgId: string; + organizationId: string; req: UpdateOrganizationRequest; } export const updateOrganization = (queryClient: QueryClient) => { return { mutationFn: (variables: UpdateOrganizationVariables) => - API.updateOrganization(variables.orgId, variables.req), + API.updateOrganization(variables.organizationId, variables.req), onSuccess: async () => { await queryClient.invalidateQueries(organizationsKey); @@ -36,7 +36,8 @@ export const updateOrganization = (queryClient: QueryClient) => { export const deleteOrganization = (queryClient: QueryClient) => { return { - mutationFn: (orgId: string) => API.deleteOrganization(orgId), + mutationFn: (organizationId: string) => + API.deleteOrganization(organizationId), onSuccess: async () => { await queryClient.invalidateQueries(meKey); @@ -79,7 +80,7 @@ export const removeOrganizationMember = ( }; }; -export const organizationsKey = ["organizations", "me"] as const; +export const organizationsKey = ["organizations"] as const; export const organizations = () => { return { diff --git a/site/src/components/PageHeader/PageHeader.tsx b/site/src/components/PageHeader/PageHeader.tsx index 036a209d03c6c..852b8616e620e 100644 --- a/site/src/components/PageHeader/PageHeader.tsx +++ b/site/src/components/PageHeader/PageHeader.tsx @@ -107,3 +107,23 @@ export const PageHeaderCaption: FC = ({ children }) => { ); }; + +interface ResourcePageHeaderProps extends Omit { + displayName?: string; + name: string; +} + +export const ResourcePageHeader: FC = ({ + displayName, + name, + ...props +}) => { + const title = displayName || name; + + return ( + + {title} + {name !== title && {name}} + + ); +}; diff --git a/site/src/pages/GroupsPage/CreateGroupPage.tsx b/site/src/pages/GroupsPage/CreateGroupPage.tsx index 16b75cb7bbb0d..11ab7371eef37 100644 --- a/site/src/pages/GroupsPage/CreateGroupPage.tsx +++ b/site/src/pages/GroupsPage/CreateGroupPage.tsx @@ -3,15 +3,13 @@ import { Helmet } from "react-helmet-async"; import { useMutation, useQueryClient } from "react-query"; import { useNavigate } from "react-router-dom"; import { createGroup } from "api/queries/groups"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { pageTitle } from "utils/page"; import CreateGroupPageView from "./CreateGroupPageView"; export const CreateGroupPage: FC = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); - const { organizationId } = useDashboard(); - const createGroupMutation = useMutation(createGroup(queryClient)); + const createGroupMutation = useMutation(createGroup(queryClient, "default")); return ( <> @@ -20,10 +18,7 @@ export const CreateGroupPage: FC = () => { { - const newGroup = await createGroupMutation.mutateAsync({ - organizationId, - ...data, - }); + const newGroup = await createGroupMutation.mutateAsync(data); navigate(`/groups/${newGroup.name}`); }} error={createGroupMutation.error} diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index b36f1f9c1cde0..0256205ad6183 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -54,10 +54,13 @@ import { isEveryoneGroup } from "utils/groups"; import { pageTitle } from "utils/page"; export const GroupPage: FC = () => { - const { groupName } = useParams() as { groupName: string }; + const { groupName, organization } = useParams() as { + organization: string; + groupName: string; + }; const queryClient = useQueryClient(); const navigate = useNavigate(); - const groupQuery = useQuery(group(groupName)); + const groupQuery = useQuery(group(organization, groupName)); const groupData = groupQuery.data; const { data: permissions } = useQuery( groupData !== undefined diff --git a/site/src/pages/GroupsPage/SettingsGroupPage.tsx b/site/src/pages/GroupsPage/SettingsGroupPage.tsx index efb7fadbce29e..66088a074c958 100644 --- a/site/src/pages/GroupsPage/SettingsGroupPage.tsx +++ b/site/src/pages/GroupsPage/SettingsGroupPage.tsx @@ -13,8 +13,7 @@ import SettingsGroupPageView from "./SettingsGroupPageView"; export const SettingsGroupPage: FC = () => { const { groupName } = useParams() as { groupName: string }; const queryClient = useQueryClient(); - const groupQuery = useQuery(group(groupName)); - const { data: groupData, isLoading, error } = useQuery(group(groupName)); + const groupQuery = useQuery(group("default", groupName)); const patchGroupMutation = useMutation(patchGroup(queryClient)); const navigate = useNavigate(); @@ -28,11 +27,11 @@ export const SettingsGroupPage: FC = () => { ); - if (error) { - return ; + if (groupQuery.error) { + return ; } - if (isLoading || !groupData) { + if (groupQuery.isLoading || !groupQuery.data) { return ( <> {helmet} @@ -40,7 +39,8 @@ export const SettingsGroupPage: FC = () => { ); } - const groupId = groupData.id; + + const groupId = groupQuery.data.id; return ( <> diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPage.tsx new file mode 100644 index 0000000000000..a51d67d63ce34 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPage.tsx @@ -0,0 +1,33 @@ +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { createGroup } from "api/queries/groups"; +import { pageTitle } from "utils/page"; +import CreateGroupPageView from "./CreateGroupPageView"; + +export const CreateGroupPage: FC = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { organization } = useParams() as { organization: string }; + const createGroupMutation = useMutation( + createGroup(queryClient, organization), + ); + + return ( + <> + + {pageTitle("Create Group")} + + { + const newGroup = await createGroupMutation.mutateAsync(data); + navigate(`/organizations/${organization}/groups/${newGroup.name}`); + }} + error={createGroupMutation.error} + isLoading={createGroupMutation.isLoading} + /> + + ); +}; +export default CreateGroupPage; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.stories.tsx new file mode 100644 index 0000000000000..a6b1783a26c0c --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/test"; +import { mockApiError } from "testHelpers/entities"; +import { CreateGroupPageView } from "./CreateGroupPageView"; + +const meta: Meta = { + title: "pages/OrganizationGroupsPage/CreateGroupPageView", + component: CreateGroupPageView, +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = {}; + +export const WithError: Story = { + args: { + error: mockApiError({ + message: "A group named new-group already exists.", + validations: [{ field: "name", detail: "Group names must be unique" }], + }), + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Enter name", async () => { + const input = canvas.getByLabelText("Name"); + await userEvent.type(input, "new-group"); + input.blur(); + }); + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.tsx new file mode 100644 index 0000000000000..f932c9c379988 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.tsx @@ -0,0 +1,90 @@ +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import type { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import * as Yup from "yup"; +import { isApiValidationError } from "api/errors"; +import type { CreateGroupRequest } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { + FormFields, + FormFooter, + FormSection, + HorizontalForm, +} from "components/Form/Form"; +import { IconField } from "components/IconField/IconField"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { getFormHelpers, onChangeTrimmed } from "utils/formUtils"; + +const validationSchema = Yup.object({ + name: Yup.string().required().label("Name"), +}); + +export type CreateGroupPageViewProps = { + onSubmit: (data: CreateGroupRequest) => void; + error?: unknown; + isLoading: boolean; +}; + +export const CreateGroupPageView: FC = ({ + onSubmit, + error, + isLoading, +}) => { + const navigate = useNavigate(); + const form = useFormik({ + initialValues: { + name: "", + display_name: "", + avatar_url: "", + quota_allowance: 0, + }, + validationSchema, + onSubmit, + }); + const getFieldHelpers = getFormHelpers(form, error); + const onCancel = () => navigate(-1); + + return ( + <> + + Create a group + + + + + {Boolean(error) && !isApiValidationError(error) && ( + + )} + + + + form.setFieldValue("avatar_url", value)} + /> + + + + + + ); +}; +export default CreateGroupPageView; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx new file mode 100644 index 0000000000000..2ab2e0c2fd8b4 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx @@ -0,0 +1,350 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import PersonAdd from "@mui/icons-material/PersonAdd"; +import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; +import LoadingButton from "@mui/lab/LoadingButton"; +import Button from "@mui/material/Button"; +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 { type FC, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link as RouterLink, useNavigate, useParams } from "react-router-dom"; +import { getErrorMessage } from "api/errors"; +import { + addMember, + deleteGroup, + group, + groupPermissions, + removeMember, +} from "api/queries/groups"; +import type { Group, ReducedUser, User } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { LastSeen } from "components/LastSeen/LastSeen"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, + ThreeDotsButton, +} from "components/MoreMenu/MoreMenu"; +import { ResourcePageHeader } from "components/PageHeader/PageHeader"; +import { Stack } from "components/Stack/Stack"; +import { + PaginationStatus, + TableToolbar, +} from "components/TableToolbar/TableToolbar"; +import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { isEveryoneGroup } from "utils/groups"; +import { pageTitle } from "utils/page"; + +export const GroupPage: FC = () => { + const { organization, groupName } = useParams() as { + organization: string; + groupName: string; + }; + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const groupQuery = useQuery(group(organization, groupName)); + const groupData = groupQuery.data; + const { data: permissions } = useQuery( + groupData !== undefined + ? groupPermissions(groupData.id) + : { enabled: false }, + ); + const addMemberMutation = useMutation(addMember(queryClient)); + const removeMemberMutation = useMutation(removeMember(queryClient)); + const deleteGroupMutation = useMutation(deleteGroup(queryClient)); + const [isDeletingGroup, setIsDeletingGroup] = useState(false); + const isLoading = groupQuery.isLoading || !groupData || !permissions; + const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; + + const helmet = ( + + + {pageTitle( + (groupData?.display_name || groupData?.name) ?? "Loading...", + )} + + + ); + + if (groupQuery.error) { + return ; + } + + if (isLoading) { + return ( + <> + {helmet} + + + ); + } + const groupId = groupData.id; + + return ( + <> + {helmet} + + + + + + + ) + } + /> + + + {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( + { + try { + await addMemberMutation.mutateAsync({ + groupId, + userId: user.id, + }); + reset(); + await groupQuery.refetch(); + } catch (error) { + displayError(getErrorMessage(error, "Failed to add member.")); + } + }} + /> + )} + + + + + + + + + User + Status + + + + + + {groupData?.members.length === 0 ? ( + + + + + + ) : ( + groupData?.members.map((member) => ( + { + try { + await removeMemberMutation.mutateAsync({ + groupId: groupData.id, + userId: member.id, + }); + await groupQuery.refetch(); + displaySuccess("Member removed successfully."); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to remove member."), + ); + } + }} + /> + )) + )} + +
+
+
+
+ + {groupQuery.data && ( + { + try { + await deleteGroupMutation.mutateAsync(groupId); + displaySuccess("Group deleted successfully."); + navigate(".."); + } catch (error) { + displayError(getErrorMessage(error, "Failed to delete group.")); + } + }} + onCancel={() => { + setIsDeletingGroup(false); + }} + /> + )} + + ); +}; + +interface AddGroupMemberProps { + isLoading: boolean; + onSubmit: (user: User, reset: () => void) => void; +} + +const AddGroupMember: FC = ({ isLoading, onSubmit }) => { + const [selectedUser, setSelectedUser] = useState(null); + + const resetValues = () => { + setSelectedUser(null); + }; + + return ( +
{ + e.preventDefault(); + + if (selectedUser) { + onSubmit(selectedUser, resetValues); + } + }} + > + + { + setSelectedUser(newValue); + }} + /> + + } + loading={isLoading} + > + Add user + + +
+ ); +}; + +interface GroupMemberRowProps { + member: ReducedUser; + group: Group; + canUpdate: boolean; + onRemove: () => void; +} + +const GroupMemberRow: FC = ({ + member, + group, + canUpdate, + onRemove, +}) => { + return ( + + + + } + title={member.username} + subtitle={member.email} + /> + + +
{member.status}
+ +
+ + {canUpdate && ( + + + + + + + Remove + + + + )} + +
+ ); +}; + +const styles = { + autoComplete: { + width: 300, + }, + removeButton: (theme) => ({ + color: theme.palette.error.main, + "&:hover": { + backgroundColor: "transparent", + }, + }), + status: { + textTransform: "capitalize", + }, + suspended: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; + +export default GroupPage; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage.tsx new file mode 100644 index 0000000000000..ca9b836c4ba5c --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage.tsx @@ -0,0 +1,74 @@ +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { getErrorMessage } from "api/errors"; +import { group, patchGroup } from "api/queries/groups"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; +import { pageTitle } from "utils/page"; +import GroupSettingsPageView from "./GroupSettingsPageView"; + +export const GroupSettingsPage: FC = () => { + const { organization, groupName } = useParams() as { + organization: string; + groupName: string; + }; + const queryClient = useQueryClient(); + const groupQuery = useQuery(group(organization, groupName)); + const patchGroupMutation = useMutation(patchGroup(queryClient)); + const navigate = useNavigate(); + + const navigateToGroup = () => { + navigate(`/organizations/${organization}/groups/${groupName}`); + }; + + const helmet = ( + + {pageTitle("Settings Group")} + + ); + + if (groupQuery.error) { + return ; + } + + if (groupQuery.isLoading || !groupQuery.data) { + return ( + <> + {helmet} + + + ); + } + const groupId = groupQuery.data.id; + + return ( + <> + {helmet} + + { + try { + await patchGroupMutation.mutateAsync({ + groupId, + ...data, + add_users: [], + remove_users: [], + }); + navigate(`../${data.name}`); + } catch (error) { + displayError(getErrorMessage(error, "Failed to update group")); + } + }} + group={groupQuery.data} + formErrors={groupQuery.error} + isLoading={groupQuery.isLoading} + isUpdating={patchGroupMutation.isLoading} + /> + + ); +}; +export default GroupSettingsPage; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.stories.tsx new file mode 100644 index 0000000000000..071fa031dbd2a --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.stories.tsx @@ -0,0 +1,21 @@ +import { action } from "@storybook/addon-actions"; +import type { Meta, StoryObj } from "@storybook/react"; +import { MockGroup } from "testHelpers/entities"; +import GroupSettingsPageView from "./GroupSettingsPageView"; + +const meta: Meta = { + title: "pages/OrganizationGroupsPage/GroupSettingsPageView", + component: GroupSettingsPageView, + args: { + onCancel: action("onCancel"), + group: MockGroup, + isLoading: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const Example: Story = {}; + +export { Example as GroupSettingsPageView }; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.tsx new file mode 100644 index 0000000000000..2b71ad58224fe --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.tsx @@ -0,0 +1,163 @@ +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import type { FC } from "react"; +import * as Yup from "yup"; +import type { Group } from "api/typesGenerated"; +import { + FormFields, + FormFooter, + FormSection, + HorizontalForm, +} from "components/Form/Form"; +import { IconField } from "components/IconField/IconField"; +import { Loader } from "components/Loader/Loader"; +import { ResourcePageHeader } from "components/PageHeader/PageHeader"; +import { + getFormHelpers, + nameValidator, + onChangeTrimmed, +} from "utils/formUtils"; +import { isEveryoneGroup } from "utils/groups"; + +type FormData = { + name: string; + display_name: string; + avatar_url: string; + quota_allowance: number; +}; + +const validationSchema = Yup.object({ + name: nameValidator("Name"), + quota_allowance: Yup.number().required().min(0).integer(), +}); + +interface UpdateGroupFormProps { + group: Group; + errors: unknown; + onSubmit: (data: FormData) => void; + onCancel: () => void; + isLoading: boolean; +} + +const UpdateGroupForm: FC = ({ + group, + errors, + onSubmit, + onCancel, + isLoading, +}) => { + const form = useFormik({ + initialValues: { + name: group.name, + display_name: group.display_name, + avatar_url: group.avatar_url, + quota_allowance: group.quota_allowance, + }, + validationSchema, + onSubmit, + }); + const getFieldHelpers = getFormHelpers(form, errors); + + return ( + + + + + {!isEveryoneGroup(group) && ( + <> + + form.setFieldValue("avatar_url", value)} + /> + + )} + + + + + + + + + + + ); +}; + +export type SettingsGroupPageViewProps = { + onCancel: () => void; + onSubmit: (data: FormData) => void; + group: Group | undefined; + formErrors: unknown; + isLoading: boolean; + isUpdating: boolean; +}; + +const GroupSettingsPageView: FC = ({ + onCancel, + onSubmit, + group, + formErrors, + isLoading, + isUpdating, +}) => { + if (isLoading) { + return ; + } + + return ( + <> + + + + ); +}; + +export default GroupSettingsPageView; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx new file mode 100644 index 0000000000000..91d727589d8b2 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx @@ -0,0 +1,65 @@ +import GroupAdd from "@mui/icons-material/GroupAddOutlined"; +import Button from "@mui/material/Button"; +import { type FC, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { Link as RouterLink } from "react-router-dom"; +import { getErrorMessage } from "api/errors"; +import { groups } from "api/queries/groups"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { pageTitle } from "utils/page"; +import { useOrganizationSettings } from "../ManagementSettingsLayout"; +import GroupsPageView from "./GroupsPageView"; + +export const GroupsPage: FC = () => { + const { permissions } = useAuthenticated(); + const { currentOrganizationId } = useOrganizationSettings(); + const { createGroup: canCreateGroup } = permissions; + const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); + const groupsQuery = useQuery(groups(currentOrganizationId!)); + + useEffect(() => { + if (groupsQuery.error) { + displayError( + getErrorMessage(groupsQuery.error, "Error on loading groups."), + ); + } + }, [groupsQuery.error]); + + return ( + <> + + {pageTitle("Groups")} + + + + {canCreateGroup && isTemplateRBACEnabled && ( + + )} + + } + > + Groups + + + + + ); +}; + +export default GroupsPage; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.stories.tsx new file mode 100644 index 0000000000000..fabd8c7104154 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockGroup } from "testHelpers/entities"; +import { GroupsPageView } from "./GroupsPageView"; + +const meta: Meta = { + title: "pages/OrganizationGroupsPage", + component: GroupsPageView, +}; + +export default meta; +type Story = StoryObj; + +export const NotEnabled: Story = { + args: { + groups: [MockGroup], + canCreateGroup: true, + isTemplateRBACEnabled: false, + }, +}; + +export const WithGroups: Story = { + args: { + groups: [MockGroup], + canCreateGroup: true, + isTemplateRBACEnabled: true, + }, +}; + +export const WithDisplayGroup: Story = { + args: { + groups: [{ ...MockGroup, name: "front-end" }], + canCreateGroup: true, + isTemplateRBACEnabled: true, + }, +}; + +export const EmptyGroup: Story = { + args: { + groups: [], + canCreateGroup: false, + isTemplateRBACEnabled: true, + }, +}; + +export const EmptyGroupWithPermission: Story = { + args: { + groups: [], + canCreateGroup: true, + isTemplateRBACEnabled: true, + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.tsx new file mode 100644 index 0000000000000..4789ad13550b5 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.tsx @@ -0,0 +1,194 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import AddOutlined from "@mui/icons-material/AddOutlined"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import AvatarGroup from "@mui/material/AvatarGroup"; +import Button from "@mui/material/Button"; +import Skeleton from "@mui/material/Skeleton"; +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 type { FC } from "react"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import type { Group } from "api/typesGenerated"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { GroupAvatar } from "components/GroupAvatar/GroupAvatar"; +import { Paywall } from "components/Paywall/Paywall"; +import { + TableLoaderSkeleton, + TableRowSkeleton, +} from "components/TableLoader/TableLoader"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { useClickableTableRow } from "hooks"; +import { docs } from "utils/docs"; + +export type GroupsPageViewProps = { + groups: Group[] | undefined; + canCreateGroup: boolean; + isTemplateRBACEnabled: boolean; +}; + +export const GroupsPageView: FC = ({ + groups, + canCreateGroup, + isTemplateRBACEnabled, +}) => { + const isLoading = Boolean(groups === undefined); + const isEmpty = Boolean(groups && groups.length === 0); + + return ( + <> + + + + + + + + + + Name + Users + + + + + + + + + + + + + } + variant="contained" + > + Create group + + ) + } + /> + + + + + + {groups?.map((group) => ( + + ))} + + + +
+
+
+
+ + ); +}; + +interface GroupRowProps { + group: Group; +} + +const GroupRow: FC = ({ group }) => { + const navigate = useNavigate(); + const rowProps = useClickableTableRow({ + onClick: () => navigate(group.name), + }); + + return ( + + + + } + title={group.display_name || group.name} + subtitle={`${group.members.length} members`} + /> + + + + {group.members.length === 0 && "-"} + + {group.members.map((member) => ( + + ))} + + + + +
+ +
+
+
+ ); +}; + +const TableLoader: FC = () => { + return ( + + + +
+ +
+
+ + + + + + +
+
+ ); +}; + +const styles = { + arrowRight: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, + }), + arrowCell: { + display: "flex", + }, +} satisfies Record>; + +export default GroupsPageView; diff --git a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx index 35563f3eb13c3..ee21518840c35 100644 --- a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx +++ b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx @@ -61,7 +61,7 @@ export const ManagementSettingsLayout: FC = () => { currentOrganizationId: !inOrganizationSettings ? undefined : !organization - ? organizationsQuery.data[0]?.id + ? "00000000-0000-0000-0000-000000000000" : organizationsQuery.data.find( (org) => org.name === organization, )?.id, diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx index 959d206c2e163..19831dc8cfbf6 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx @@ -37,10 +37,12 @@ const OrganizationSettingsPage: FC = () => { organization={org} error={error} onSubmit={async (values) => { - await updateOrganizationMutation.mutateAsync({ - orgId: org.id, - req: values, - }); + const updatedOrganization = + await updateOrganizationMutation.mutateAsync({ + organizationId: org.id, + req: values, + }); + navigate(`/organizations/${updatedOrganization.name}`); displaySuccess("Organization settings updated."); }} onDeleteOrganization={() => { diff --git a/site/src/pages/ManagementSettingsPage/Sidebar.tsx b/site/src/pages/ManagementSettingsPage/Sidebar.tsx index dde4ef35664bd..cc5fbb5d97e1c 100644 --- a/site/src/pages/ManagementSettingsPage/Sidebar.tsx +++ b/site/src/pages/ManagementSettingsPage/Sidebar.tsx @@ -78,7 +78,6 @@ const DeploymentSettingsNavigation: FC = () => { Observability Users - Groups )} @@ -115,23 +114,15 @@ export const OrganizationSettingsNavigation: FC< {active && ( - + Organization settings - - External authentication - Members Groups - - Metrics - @@ -187,18 +178,20 @@ export const SidebarNavItem: FC = ({ interface SidebarNavSubItemProps { children?: ReactNode; href: string; + end?: boolean; } export const SidebarNavSubItem: FC = ({ children, href, + end, }) => { const link = useClassName(classNames.subLink, []); const activeLink = useClassName(classNames.activeSubLink, []); return ( cx([link, isActive && activeLink])} > diff --git a/site/src/pages/UsersPage/UsersLayout.tsx b/site/src/pages/UsersPage/UsersLayout.tsx index 8b3dc7858c41e..ac11e008e436d 100644 --- a/site/src/pages/UsersPage/UsersLayout.tsx +++ b/site/src/pages/UsersPage/UsersLayout.tsx @@ -13,11 +13,13 @@ import { Margins } from "components/Margins/Margins"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { USERS_LINK } from "modules/navigation"; export const UsersLayout: FC = () => { const { permissions } = useAuthenticated(); + const { experiments } = useDashboard(); const { createUser: canCreateUser, createGroup: canCreateGroup } = permissions; const navigate = useNavigate(); @@ -57,21 +59,23 @@ export const UsersLayout: FC = () => { - - - - - Users - - - Groups - - - - + {!experiments.includes("multi-organization") && ( + + + + + Users + + + Groups + + + + + )} }> diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx index 8ef7386f01534..1ead5d7863b38 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx @@ -78,7 +78,7 @@ export const WorkspaceSettingsForm: FC = ({ workspace.allow_renames ? form.values.name !== form.initialValues.name && "Depending on the template, renaming your workspace may be destructive" - : "Renaming your workspace can be destructive and has not been enabled for this deployment." + : "Renaming your workspace can be destructive and is disabled by the template." } /> diff --git a/site/src/router.tsx b/site/src/router.tsx index 5b6f013f715c4..36525099574f9 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -227,6 +227,18 @@ const CreateOrganizationPage = lazy( const OrganizationSettingsPage = lazy( () => import("./pages/ManagementSettingsPage/OrganizationSettingsPage"), ); +const OrganizationGroupsPage = lazy( + () => import("./pages/ManagementSettingsPage/GroupsPage/GroupsPage"), +); +const CreateOrganizationGroupPage = lazy( + () => import("./pages/ManagementSettingsPage/GroupsPage/CreateGroupPage"), +); +const OrganizationGroupPage = lazy( + () => import("./pages/ManagementSettingsPage/GroupsPage/GroupPage"), +); +const OrganizationGroupSettingsPage = lazy( + () => import("./pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage"), +); const OrganizationMembersPage = lazy( () => import("./pages/ManagementSettingsPage/OrganizationMembersPage"), ); @@ -347,19 +359,20 @@ export const router = createBrowserRouter( } /> - } - /> } /> - } - /> - } - /> + + } /> + + } + /> + } /> + } + /> + }