diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f125dbca3dc58..fee6770b933ac 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -61,7 +61,7 @@ type OrganizationMember struct { type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,organization_name"` // DisplayName will default to the same value as `Name` if not provided. - DisplayName string `json:"display_name" validate:"omitempty,organization_display_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"` Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2a1057ef04b4a..50dbc32a1867d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -505,6 +505,31 @@ class ApiMethods { return response.data; }; + createOrganization = async (params: TypesGen.CreateOrganizationRequest) => { + const response = await this.axios.post( + "/api/v2/organizations", + params, + ); + return response.data; + }; + + updateOrganization = async ( + orgId: string, + params: TypesGen.UpdateOrganizationRequest, + ) => { + const response = await this.axios.patch( + `/api/v2/organizations/${orgId}`, + params, + ); + return response.data; + }; + + deleteOrganization = async (orgId: string) => { + await this.axios.delete( + `/api/v2/organizations/${orgId}`, + ); + }; + getOrganization = async ( organizationId: string, ): Promise => { diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts new file mode 100644 index 0000000000000..e9526e74ca3f2 --- /dev/null +++ b/site/src/api/queries/organizations.ts @@ -0,0 +1,46 @@ +import type { QueryClient } from "react-query"; +import { API } from "api/api"; +import type { + CreateOrganizationRequest, + UpdateOrganizationRequest, +} from "api/typesGenerated"; +import { meKey, myOrganizationsKey } from "./users"; + +export const createOrganization = (queryClient: QueryClient) => { + return { + mutationFn: (params: CreateOrganizationRequest) => + API.createOrganization(params), + + onSuccess: async () => { + await queryClient.invalidateQueries(meKey); + await queryClient.invalidateQueries(myOrganizationsKey); + }, + }; +}; + +interface UpdateOrganizationVariables { + orgId: string; + req: UpdateOrganizationRequest; +} + +export const updateOrganization = (queryClient: QueryClient) => { + return { + mutationFn: (variables: UpdateOrganizationVariables) => + API.updateOrganization(variables.orgId, variables.req), + + onSuccess: async () => { + await queryClient.invalidateQueries(myOrganizationsKey); + }, + }; +}; + +export const deleteOrganization = (queryClient: QueryClient) => { + return { + mutationFn: (orgId: string) => API.deleteOrganization(orgId), + + onSuccess: async () => { + await queryClient.invalidateQueries(meKey); + await queryClient.invalidateQueries(myOrganizationsKey); + }, + }; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index cf70038e7ca23..db43fa46620f5 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -124,7 +124,7 @@ export const authMethods = () => { }; }; -const meKey = ["me"]; +export const meKey = ["me"]; export const me = (metadata: MetadataState) => { return cachedQuery({ @@ -250,9 +250,11 @@ export const updateAppearanceSettings = ( }; }; +export const myOrganizationsKey = ["organizations", "me"] as const; + export const myOrganizations = () => { return { - queryKey: ["organizations", "me"], + queryKey: myOrganizationsKey, queryFn: () => API.getOrganizations(), }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 971fae1149075..819e32c38d969 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -226,7 +226,7 @@ export interface CreateGroupRequest { // From codersdk/organizations.go export interface CreateOrganizationRequest { readonly name: string; - readonly display_name: string; + readonly display_name?: string; readonly description?: string; readonly icon?: string; } diff --git a/site/src/components/FormFooter/FormFooter.stories.tsx b/site/src/components/FormFooter/FormFooter.stories.tsx index 41d44250d04e1..20af1c5b437e4 100644 --- a/site/src/components/FormFooter/FormFooter.stories.tsx +++ b/site/src/components/FormFooter/FormFooter.stories.tsx @@ -1,23 +1,31 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { FormFooter } from "./FormFooter"; const meta: Meta = { title: "components/FormFooter", component: FormFooter, + args: { + isLoading: false, + onCancel: action("onCancel"), + }, }; export default meta; type Story = StoryObj; export const Ready: Story = { + args: {}, +}; + +export const NoCancel: Story = { args: { - isLoading: false, + onCancel: undefined, }, }; export const Custom: Story = { args: { - isLoading: false, submitLabel: "Create", }, }; diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 4c672cf8d8ee9..394268be48efe 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -14,7 +14,7 @@ export interface FormFooterStyles { } export interface FormFooterProps { - onCancel: () => void; + onCancel?: () => void; isLoading: boolean; styles?: FormFooterStyles; submitLabel?: string; @@ -45,15 +45,17 @@ export const FormFooter: FC = ({ > {submitLabel} - + {onCancel && ( + + )} {extraActions} ); diff --git a/site/src/components/Margins/Margins.tsx b/site/src/components/Margins/Margins.tsx index 9c03d2626174d..f5b120dded58d 100644 --- a/site/src/components/Margins/Margins.tsx +++ b/site/src/components/Margins/Margins.tsx @@ -13,8 +13,13 @@ const widthBySize: Record = { small: containerWidth / 3, }; -export const Margins: FC = ({ +type MarginsProps = JSX.IntrinsicElements["div"] & { + size?: Size; +}; + +export const Margins: FC = ({ size = "regular", + children, ...divProps }) => { const maxWidth = widthBySize[size]; @@ -22,11 +27,15 @@ export const Margins: FC = ({
+ > + {children} +
); }; diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 23f0355ad3e9a..e54210d831d8e 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -13,22 +13,25 @@ import { import { USERS_LINK } from "modules/navigation"; interface DeploymentDropdownProps { - canViewAuditLog: boolean; canViewDeployment: boolean; + canViewOrganizations: boolean; canViewAllUsers: boolean; + canViewAuditLog: boolean; canViewHealth: boolean; } export const DeploymentDropdown: FC = ({ - canViewAuditLog, canViewDeployment, + canViewOrganizations, canViewAllUsers, + canViewAuditLog, canViewHealth, }) => { const theme = useTheme(); if ( !canViewAuditLog && + !canViewOrganizations && !canViewDeployment && !canViewAllUsers && !canViewHealth @@ -64,9 +67,10 @@ export const DeploymentDropdown: FC = ({ }} > @@ -75,9 +79,10 @@ export const DeploymentDropdown: FC = ({ }; const DeploymentDropdownContent: FC = ({ - canViewAuditLog, canViewDeployment, + canViewOrganizations, canViewAllUsers, + canViewAuditLog, canViewHealth, }) => { const popover = usePopover(); @@ -96,6 +101,16 @@ const DeploymentDropdownContent: FC = ({ Settings )} + {canViewOrganizations && ( + + Organizations + + )} {canViewAllUsers && ( { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const { appearance } = useDashboard(); + const { appearance, experiments } = useDashboard(); const { user: me, permissions, signOut } = useAuthenticated(); const featureVisibility = useFeatureVisibility(); const canViewAuditLog = @@ -29,10 +29,11 @@ export const Navbar: FC = () => { buildInfo={buildInfoQuery.data} supportLinks={appearance.support_links} onSignOut={signOut} - canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} + canViewOrganizations={experiments.includes("multi-organization")} canViewAllUsers={canViewAllUsers} canViewHealth={canViewHealth} + canViewAuditLog={canViewAuditLog} proxyContextValue={proxyContextValue} /> ); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index a6541ea688486..02b40065905dc 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -28,10 +28,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const workspacesLink = await screen.findByText(navLanguage.workspaces); @@ -44,10 +45,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const templatesLink = await screen.findByText(navLanguage.templates); @@ -60,10 +62,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const deploymentMenu = await screen.findByText("Deployment"); @@ -78,10 +81,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const deploymentMenu = await screen.findByText("Deployment"); @@ -96,10 +100,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const deploymentMenu = await screen.findByText("Deployment"); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 06e847ef76a3a..77733bc63e920 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -19,9 +19,10 @@ export interface NavbarViewProps { buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; - canViewAuditLog: boolean; canViewDeployment: boolean; + canViewOrganizations: boolean; canViewAllUsers: boolean; + canViewAuditLog: boolean; canViewHealth: boolean; proxyContextValue?: ProxyContextValue; } @@ -69,10 +70,11 @@ export const NavbarView: FC = ({ buildInfo, supportLinks, onSignOut, - canViewAuditLog, canViewDeployment, + canViewOrganizations, canViewAllUsers, canViewHealth, + canViewAuditLog, proxyContextValue, }) => { const theme = useTheme(); @@ -134,6 +136,7 @@ export const NavbarView: FC = ({ = ({ onSignOut, }) => { const theme = useTheme(); - const organizationsQuery = useQuery({ - ...myOrganizations(), - enabled: Boolean(localStorage.getItem("enableMultiOrganizationUi")), - }); - const { organizationId, setOrganizationId } = useDashboard(); return ( @@ -71,9 +63,6 @@ export const UserDropdown: FC = ({ user={user} buildInfo={buildInfo} supportLinks={supportLinks} - organizations={organizationsQuery.data} - organizationId={organizationId} - setOrganizationId={setOrganizationId} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index c0ad5111ea9ae..b8766698d4ca7 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -29,9 +29,6 @@ export const Language = { export interface UserDropdownContentProps { user: TypesGen.User; - organizations?: TypesGen.Organization[]; - organizationId?: string; - setOrganizationId?: (id: string) => void; buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; @@ -39,9 +36,6 @@ export interface UserDropdownContentProps { export const UserDropdownContent: FC = ({ user, - organizations, - organizationId, - setOrganizationId, buildInfo, supportLinks, onSignOut, @@ -79,43 +73,6 @@ export const UserDropdownContent: FC = ({ - {organizations && ( - <> -
-
- My teams -
- {organizations.map((org) => ( - { - setOrganizationId?.(org.id); - popover.setIsOpen(false); - }} - > - {/* */} - - {org.name} - {organizationId === org.id && ( - Current - )} - - - ))} -
- - - )} - diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index e79da49a5337e..893de4d6bd688 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockTemplate, @@ -15,6 +16,7 @@ const meta: Meta = { component: CreateTemplateForm, args: { isSubmitting: false, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 8370be000e9c1..bbc7f45288385 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -25,7 +25,7 @@ import { nameValidator, getFormHelpers, onChangeTrimmed, - templateDisplayNameValidator, + displayNameValidator, } from "utils/formUtils"; import { sortedDays, @@ -57,7 +57,7 @@ export interface CreateTemplateData { const validationSchema = Yup.object({ name: nameValidator("Name"), - display_name: templateDisplayNameValidator("Display name"), + display_name: displayNameValidator("Display name"), description: Yup.string().max( MAX_DESCRIPTION_CHAR_LIMIT, "Please enter a description that is less than or equal to 128 characters.", diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 537c0280ba03d..a47d4b7b4c460 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; import { @@ -26,6 +27,7 @@ const meta: Meta = { permissions: { createWorkspaceForUser: true, }, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx index 48463eb1fc0a2..c715c82d74110 100644 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockGroup } from "testHelpers/entities"; import { SettingsGroupPageView } from "./SettingsGroupPageView"; @@ -5,16 +6,16 @@ import { SettingsGroupPageView } from "./SettingsGroupPageView"; const meta: Meta = { title: "pages/GroupsPage/SettingsGroupPageView", component: SettingsGroupPageView, -}; - -export default meta; -type Story = StoryObj; - -const Example: Story = { args: { + onCancel: action("onCancel"), group: MockGroup, isLoading: false, }, }; +export default meta; +type Story = StoryObj; + +const Example: Story = {}; + export { Example as SettingsGroupPageView }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx new file mode 100644 index 0000000000000..ae278b053428a --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx @@ -0,0 +1,74 @@ +import { createContext, type FC, Suspense, useContext } from "react"; +import { useQuery } from "react-query"; +import { Outlet, useParams } from "react-router-dom"; +import { myOrganizations } from "api/queries/users"; +import type { Organization } from "api/typesGenerated"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { Stack } from "components/Stack/Stack"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { RequirePermission } from "contexts/auth/RequirePermission"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import NotFoundPage from "pages/404Page/404Page"; +import { Sidebar } from "./Sidebar"; + +type OrganizationSettingsContextValue = { + currentOrganizationId: string; + organizations: Organization[]; +}; + +const OrganizationSettingsContext = createContext< + OrganizationSettingsContextValue | undefined +>(undefined); + +export const useOrganizationSettings = (): OrganizationSettingsContextValue => { + const context = useContext(OrganizationSettingsContext); + if (!context) { + throw new Error( + "useOrganizationSettings should be used inside of OrganizationSettingsLayout", + ); + } + return context; +}; + +export const OrganizationSettingsLayout: FC = () => { + const { permissions, organizationIds } = useAuthenticated(); + const { experiments } = useDashboard(); + const { organization } = useParams() as { organization: string }; + const organizationsQuery = useQuery(myOrganizations()); + + const multiOrgExperimentEnabled = experiments.includes("multi-organization"); + + if (!multiOrgExperimentEnabled) { + return ; + } + + return ( + + + + {organizationsQuery.data ? ( + org.name === organization, + )?.id ?? organizationIds[0], + organizations: organizationsQuery.data, + }} + > + +
+ }> + + +
+
+ ) : ( + + )} +
+
+
+ ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx new file mode 100644 index 0000000000000..bc278b79c7e42 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -0,0 +1,192 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import { type FC, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import * as Yup from "yup"; +import { + createOrganization, + updateOrganization, + deleteOrganization, +} from "api/queries/organizations"; +import type { UpdateOrganizationRequest } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { + FormFields, + FormSection, + HorizontalForm, + FormFooter, +} from "components/Form/Form"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { IconField } from "components/IconField/IconField"; +import { Margins } from "components/Margins/Margins"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { Stack } from "components/Stack/Stack"; +import { + getFormHelpers, + nameValidator, + displayNameValidator, + onChangeTrimmed, +} from "utils/formUtils"; +import { useOrganizationSettings } from "./OrganizationSettingsLayout"; + +const MAX_DESCRIPTION_CHAR_LIMIT = 128; +const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; + +export const validationSchema = Yup.object({ + name: nameValidator("Name"), + display_name: displayNameValidator("Display name"), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + MAX_DESCRIPTION_MESSAGE, + ), +}); + +const OrganizationSettingsPage: FC = () => { + const queryClient = useQueryClient(); + const addOrganizationMutation = useMutation(createOrganization(queryClient)); + const updateOrganizationMutation = useMutation( + updateOrganization(queryClient), + ); + const deleteOrganizationMutation = useMutation( + deleteOrganization(queryClient), + ); + + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + const org = organizations.find((org) => org.id === currentOrganizationId)!; + + const error = + updateOrganizationMutation.error ?? + addOrganizationMutation.error ?? + deleteOrganizationMutation.error; + + const form = useFormik({ + initialValues: { + name: org.name, + display_name: org.display_name, + description: org.description, + icon: org.icon, + }, + validationSchema, + onSubmit: async (values) => { + await updateOrganizationMutation.mutateAsync({ + orgId: org.id, + req: values, + }); + displaySuccess("Organization settings updated."); + }, + enableReinitialize: true, + }); + const getFieldHelpers = getFormHelpers(form, error); + + const [newOrgName, setNewOrgName] = useState(""); + + return ( + + {Boolean(error) && } + + + Organization settings + + + + +
+ + + + + form.setFieldValue("icon", value)} + /> + +
+
+ +
+ + {!org.is_default && ( + + )} + + + setNewOrgName(event.target.value)} + /> + + +
+ ); +}; + +export default OrganizationSettingsPage; + +const styles = { + dangerButton: (theme) => ({ + "&.MuiButton-contained": { + backgroundColor: theme.roles.danger.fill.solid, + borderColor: theme.roles.danger.fill.outline, + + "&:not(.MuiLoadingButton-loading)": { + color: theme.roles.danger.fill.text, + }, + + "&:hover:not(:disabled)": { + backgroundColor: theme.roles.danger.hover.fill.solid, + borderColor: theme.roles.danger.hover.fill.outline, + }, + + "&.Mui-disabled": { + backgroundColor: theme.roles.danger.disabled.background, + borderColor: theme.roles.danger.disabled.outline, + + "&:not(.MuiLoadingButton-loading)": { + color: theme.roles.danger.disabled.fill.text, + }, + }, + }, + }), +} satisfies Record>; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx new file mode 100644 index 0000000000000..d0b3d95bc894c --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx @@ -0,0 +1,37 @@ +import type { FC } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { + createOrganization, + deleteOrganization, +} from "api/queries/organizations"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Margins } from "components/Margins/Margins"; +import { useOrganizationSettings } from "./OrganizationSettingsLayout"; + +const OrganizationSettingsPage: FC = () => { + const queryClient = useQueryClient(); + const addOrganizationMutation = useMutation(createOrganization(queryClient)); + const deleteOrganizationMutation = useMutation( + deleteOrganization(queryClient), + ); + + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + const org = organizations.find((org) => org.id === currentOrganizationId)!; + + const error = + addOrganizationMutation.error ?? deleteOrganizationMutation.error; + + return ( + + {Boolean(error) && } + +

Organization settings

+ +

Name: {org.name}

+

Display name: {org.display_name}

+
+ ); +}; + +export default OrganizationSettingsPage; diff --git a/site/src/pages/OrganizationSettingsPage/Sidebar.tsx b/site/src/pages/OrganizationSettingsPage/Sidebar.tsx new file mode 100644 index 0000000000000..20b45d44de344 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/Sidebar.tsx @@ -0,0 +1,182 @@ +import { cx } from "@emotion/css"; +import type { FC, ReactNode } from "react"; +import { Link, NavLink } from "react-router-dom"; +import type { Organization } from "api/typesGenerated"; +import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; +import { Stack } from "components/Stack/Stack"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { type ClassName, useClassName } from "hooks/useClassName"; +import { useOrganizationSettings } from "./OrganizationSettingsLayout"; + +export const Sidebar: FC = () => { + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + // TODO: Do something nice to scroll to the active org. + + return ( + + {organizations.map((organization) => ( + + ))} + + ); +}; + +interface BloobProps { + organization: Organization; + active: boolean; +} + +function urlForSubpage(organizationName: string, subpage: string = ""): string { + return `/organizations/${organizationName}/${subpage}`; +} + +export const OrganizationSettingsNavigation: FC = ({ + organization, + active, +}) => { + return ( + <> + + } + > + {organization.display_name} + + {active && ( + + + Organization settings + + + External authentication + + + Members + + + Groups + + + Metrics + + + Auditing + + + )} + + ); +}; + +interface SidebarNavItemProps { + active?: boolean; + children?: ReactNode; + icon: ReactNode; + href: string; +} + +export const SidebarNavItem: FC = ({ + active, + children, + href, + icon, +}) => { + const link = useClassName(classNames.link, []); + const activeLink = useClassName(classNames.activeLink, []); + + return ( + + + {icon} + {children} + + + ); +}; + +interface SidebarNavSubItemProps { + children?: ReactNode; + href: string; +} + +export const SidebarNavSubItem: FC = ({ + children, + href, +}) => { + const link = useClassName(classNames.subLink, []); + const activeLink = useClassName(classNames.activeSubLink, []); + + return ( + cx([link, isActive && activeLink])} + > + {children} + + ); +}; + +const classNames = { + link: (css, theme) => css` + color: inherit; + display: block; + font-size: 14px; + text-decoration: none; + padding: 10px 12px 10px 16px; + border-radius: 4px; + transition: background-color 0.15s ease-in-out; + position: relative; + + &:hover { + background-color: ${theme.palette.action.hover}; + } + + border-left: 3px solid transparent; + `, + + activeLink: (css, theme) => css` + border-left-color: ${theme.palette.primary.main}; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + `, + + subLink: (css, theme) => css` + color: inherit; + text-decoration: none; + + display: block; + font-size: 13px; + margin-left: 42px; + padding: 4px 12px; + border-radius: 4px; + transition: background-color 0.15s ease-in-out; + margin-bottom: 1px; + position: relative; + + &:hover { + background-color: ${theme.palette.action.hover}; + } + `, + + activeSubLink: (css) => css` + font-weight: 600; + `, +} satisfies Record; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 3e6cc138426ca..afada2f27a336 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -3,7 +3,7 @@ import FormControlLabel from "@mui/material/FormControlLabel"; import FormHelperText from "@mui/material/FormHelperText"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; -import { type FormikContextType, type FormikTouched, useFormik } from "formik"; +import { type FormikTouched, useFormik } from "formik"; import type { FC } from "react"; import * as Yup from "yup"; import { @@ -27,29 +27,27 @@ import { import { getFormHelpers, nameValidator, - templateDisplayNameValidator, + displayNameValidator, onChangeTrimmed, iconValidator, } from "utils/formUtils"; const MAX_DESCRIPTION_CHAR_LIMIT = 128; -const MAX_DESCRIPTION_MESSAGE = - "Please enter a description that is no longer than 128 characters."; +const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; -export const getValidationSchema = (): Yup.AnyObjectSchema => - Yup.object({ - name: nameValidator("Name"), - display_name: templateDisplayNameValidator("Display name"), - description: Yup.string().max( - MAX_DESCRIPTION_CHAR_LIMIT, - MAX_DESCRIPTION_MESSAGE, - ), - allow_user_cancel_workspace_jobs: Yup.boolean(), - icon: iconValidator, - require_active_version: Yup.boolean(), - deprecation_message: Yup.string(), - max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels), - }); +export const validationSchema = Yup.object({ + name: nameValidator("Name"), + display_name: displayNameValidator("Display name"), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + MAX_DESCRIPTION_MESSAGE, + ), + allow_user_cancel_workspace_jobs: Yup.boolean(), + icon: iconValidator, + require_active_version: Yup.boolean(), + deprecation_message: Yup.string(), + max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels), +}); export interface TemplateSettingsForm { template: Template; @@ -75,27 +73,25 @@ export const TemplateSettingsForm: FC = ({ advancedSchedulingEnabled, portSharingControlsEnabled, }) => { - const validationSchema = getValidationSchema(); - const form: FormikContextType = - useFormik({ - initialValues: { - name: template.name, - display_name: template.display_name, - description: template.description, - icon: template.icon, - allow_user_cancel_workspace_jobs: - template.allow_user_cancel_workspace_jobs, - update_workspace_last_used_at: false, - update_workspace_dormant_at: false, - require_active_version: template.require_active_version, - deprecation_message: template.deprecation_message, - disable_everyone_group_access: false, - max_port_share_level: template.max_port_share_level, - }, - validationSchema, - onSubmit, - initialTouched, - }); + const form = useFormik({ + initialValues: { + name: template.name, + display_name: template.display_name, + description: template.description, + icon: template.icon, + allow_user_cancel_workspace_jobs: + template.allow_user_cancel_workspace_jobs, + update_workspace_last_used_at: false, + update_workspace_dormant_at: false, + require_active_version: template.require_active_version, + deprecation_message: template.deprecation_message, + disable_everyone_group_access: false, + max_port_share_level: template.max_port_share_level, + }, + validationSchema, + onSubmit, + initialTouched, + }); const getFieldHelpers = getFormHelpers(form, error); return ( diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 716322f982288..7e7b44d8684d1 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -10,7 +10,7 @@ import { waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; -import { getValidationSchema } from "./TemplateSettingsForm"; +import { validationSchema } from "./TemplateSettingsForm"; import { TemplateSettingsPage } from "./TemplateSettingsPage"; type FormValues = Required< @@ -116,9 +116,9 @@ describe("TemplateSettingsPage", () => { const values: UpdateTemplateMeta = { ...validFormValues, description: - "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port", + "The quick brown fox jumps over the lazy dog repeatedly, enjoying the weather of the bright, summer day in the lush, scenic park.", }; - const validate = () => getValidationSchema().validateSync(values); + const validate = () => validationSchema.validateSync(values); expect(validate).not.toThrowError(); }); @@ -126,9 +126,9 @@ describe("TemplateSettingsPage", () => { const values: UpdateTemplateMeta = { ...validFormValues, description: - "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a", + "The quick brown fox jumps over the lazy dog multiple times, enjoying the warmth of the bright, sunny day in the lush, green park.", }; - const validate = () => getValidationSchema().validateSync(values); + const validate = () => validationSchema.validateSync(values); expect(validate).toThrowError(); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx index 1d63e8ade1cc0..5b3078af46bb6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { mockApiError, MockTemplate } from "testHelpers/entities"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; @@ -9,6 +10,7 @@ const meta: Meta = { template: MockTemplate, accessControlEnabled: true, advancedSchedulingEnabled: true, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx index ee03b8c3f3435..7cf1ba07a2ef6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { mockApiError, @@ -13,6 +14,9 @@ import { TemplateVariablesPageView } from "./TemplateVariablesPageView"; const meta: Meta = { title: "pages/TemplateSettingsPage/TemplateVariablesPageView", component: TemplateVariablesPageView, + args: { + onCancel: action("onCancel"), + }, }; export default meta; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 3a299e37b20aa..55bebfb1b53ec 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -1,8 +1,6 @@ -import Button from "@mui/material/Button"; -import { type FC, useEffect, useState } from "react"; +import type { FC } from "react"; import { useQuery } from "react-query"; import { groupsForUser } from "api/queries/groups"; -import { DisabledBadge, EnabledBadge } from "components/Badges/Badges"; import { Stack } from "components/Stack/Stack"; import { useAuthContext } from "contexts/auth/AuthProvider"; import { useAuthenticated } from "contexts/auth/RequireAuth"; @@ -15,7 +13,7 @@ export const AccountPage: FC = () => { const { permissions, user: me } = useAuthenticated(); const { updateProfile, updateProfileError, isUpdatingProfile } = useAuthContext(); - const { entitlements, experiments, organizationId } = useDashboard(); + const { entitlements, organizationId } = useDashboard(); const hasGroupsFeature = entitlements.features.user_role_management.enabled; const groupsQuery = useQuery({ @@ -23,21 +21,6 @@ export const AccountPage: FC = () => { enabled: hasGroupsFeature, }); - const multiOrgExperimentEnabled = experiments.includes("multi-organization"); - const [multiOrgUiEnabled, setMultiOrgUiEnabled] = useState( - () => - multiOrgExperimentEnabled && - Boolean(localStorage.getItem("enableMultiOrganizationUi")), - ); - - useEffect(() => { - if (multiOrgUiEnabled) { - localStorage.setItem("enableMultiOrganizationUi", "true"); - } else { - localStorage.removeItem("enableMultiOrganizationUi"); - } - }, [multiOrgUiEnabled]); - return (
@@ -58,23 +41,6 @@ export const AccountPage: FC = () => { error={groupsQuery.error} /> )} - - {multiOrgExperimentEnabled && ( -
Danger: enabling will break things in the UI. - } - > - - {multiOrgUiEnabled ? : } - - -
- )} ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx index 0314fa177ace0..a7e29c61dcec9 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockWorkspaceBuildParameter1, @@ -19,6 +20,7 @@ const meta: Meta = { isSubmitting: false, workspace: MockWorkspace, canChangeVersions: true, + onCancel: action("onCancel"), data: { buildParameters: [ diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx index a67f17bb07c68..1a548db9bf88e 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; @@ -37,6 +38,7 @@ const meta: Meta = { component: WorkspaceScheduleForm, args: { template: mockTemplate, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx index b45281c0f4a9b..fff7f647a4ce6 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockWorkspace } from "testHelpers/entities"; import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView"; @@ -8,6 +9,7 @@ const meta: Meta = { args: { error: undefined, workspace: MockWorkspace, + onCancel: action("onCancel"), }, }; diff --git a/site/src/router.tsx b/site/src/router.tsx index de288d37d3941..e2685c29f69c8 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -13,6 +13,7 @@ import AuditPage from "./pages/AuditPage/AuditPage"; import { DeploySettingsLayout } from "./pages/DeploySettingsPage/DeploySettingsLayout"; import { HealthLayout } from "./pages/HealthPage/HealthLayout"; import LoginPage from "./pages/LoginPage/LoginPage"; +import { OrganizationSettingsLayout } from "./pages/OrganizationSettingsPage/OrganizationSettingsLayout"; import { SetupPage } from "./pages/SetupPage/SetupPage"; import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout"; import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout"; @@ -220,6 +221,13 @@ const AddNewLicensePage = lazy( () => import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"), ); +const OrganizationSettingsPage = lazy( + () => import("./pages/OrganizationSettingsPage/OrganizationSettingsPage"), +); +const OrganizationSettingsPlaceholder = lazy( + () => + import("./pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder"), +); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), ); @@ -325,6 +333,33 @@ export const router = createBrowserRouter( } /> + } + > + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + + }> } /> } /> diff --git a/site/src/utils/formUtils.ts b/site/src/utils/formUtils.ts index c48eeb301383f..846414eecd95b 100644 --- a/site/src/utils/formUtils.ts +++ b/site/src/utils/formUtils.ts @@ -18,7 +18,7 @@ const Language = { nameTooLong: (name: string, len: number): string => { return `${name} cannot be longer than ${len} characters`; }, - templateDisplayNameInvalidChars: (name: string): string => { + displayNameInvalidChars: (name: string): string => { return `${name} must start and end with non-whitespace character`; }, }; @@ -114,9 +114,9 @@ export const onChangeTrimmed = // REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go const maxLenName = 32; -const templateDisplayNameMaxLength = 64; +const displayNameMaxLength = 64; const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/; -const templateDisplayNameRE = /^[^\s](.*[^\s])?$/; +const displayNameRE = /^[^\s](.*[^\s])?$/; // REMARK: see #1756 for name/username semantics export const nameValidator = (name: string): Yup.StringSchema => @@ -125,17 +125,12 @@ export const nameValidator = (name: string): Yup.StringSchema => .matches(usernameRE, Language.nameInvalidChars(name)) .max(maxLenName, Language.nameTooLong(name, maxLenName)); -export const templateDisplayNameValidator = ( - displayName: string, -): Yup.StringSchema => +export const displayNameValidator = (displayName: string): Yup.StringSchema => Yup.string() - .matches( - templateDisplayNameRE, - Language.templateDisplayNameInvalidChars(displayName), - ) + .matches(displayNameRE, Language.displayNameInvalidChars(displayName)) .max( - templateDisplayNameMaxLength, - Language.nameTooLong(displayName, templateDisplayNameMaxLength), + displayNameMaxLength, + Language.nameTooLong(displayName, displayNameMaxLength), ) .optional();