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(); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy