diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 5b9e5254d2930..278745115fd97 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -53,6 +53,17 @@ export const createGroup = async (orgId: string) => { return group; }; +export const createOrganization = async () => { + const name = randomName(); + const org = await API.createOrganization({ + name, + display_name: `Org ${name}`, + description: `Org description ${name}`, + icon: "/emojis/1f957.png", + }); + return org; +}; + export async function verifyConfigFlagBoolean( page: Page, config: DeploymentConfig, diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 889976fe4615b..320bf9ed2dd88 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -147,7 +147,7 @@ export default defineConfig({ gitAuth.validatePath, ), CODER_PPROF_ADDRESS: "127.0.0.1:" + coderdPProfPort, - CODER_EXPERIMENTS: e2eFakeExperiment1 + "," + e2eFakeExperiment2, + CODER_EXPERIMENTS: `multi-organization,${e2eFakeExperiment1},${e2eFakeExperiment2}`, // Tests for Deployment / User Authentication / OIDC CODER_OIDC_ISSUER_URL: "https://accounts.google.com", diff --git a/site/e2e/tests/organizations.spec.ts b/site/e2e/tests/organizations.spec.ts new file mode 100644 index 0000000000000..01c9710a98a22 --- /dev/null +++ b/site/e2e/tests/organizations.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "@playwright/test"; +import { setupApiCalls } from "../api"; +import { expectUrl } from "../expectUrl"; +import { requiresEnterpriseLicense } from "../helpers"; +import { beforeCoderTest } from "../hooks"; + +test.beforeEach(async ({ page }) => { + await beforeCoderTest(page); + await setupApiCalls(page); +}); + +test("create and delete organization", async ({ page, baseURL }) => { + requiresEnterpriseLicense(); + + // Create an organization + await page.goto(`${baseURL}/organizations/new`, { + waitUntil: "domcontentloaded", + }); + + await page.getByLabel("Name", { exact: true }).fill("floop"); + await page.getByLabel("Display name").fill("Floop"); + await page.getByLabel("Description").fill("Org description floop"); + await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png"); + + await page.getByRole("button", { name: "Submit" }).click(); + + // Expect to be redirected to the new organization + await expectUrl(page).toHavePathName("/organizations/floop"); + await expect(page.getByText("Organization created.")).toBeVisible(); + + await page.getByRole("button", { name: "Delete this organization" }).click(); + const dialog = page.getByTestId("dialog"); + await dialog.getByLabel("Name").fill("floop"); + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByText("Organization deleted.")).toBeVisible(); +}); diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx index 9e5c092b27a45..7ae91d8acc0fc 100644 --- a/site/src/components/Alert/Alert.tsx +++ b/site/src/components/Alert/Alert.tsx @@ -52,7 +52,7 @@ export const Alert: FC = ({ size="small" onClick={() => { setOpen(false); - onDismiss && onDismiss(); + onDismiss?.(); }} data-testid="dismiss-banner-btn" > diff --git a/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx b/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx index a100d5a99abcb..bed83a9ee820f 100644 --- a/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx +++ b/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx @@ -8,13 +8,15 @@ 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 { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout"; import { Sidebar } from "./Sidebar"; type DeploySettingsContextValue = { deploymentValues: DeploymentConfig; }; -const DeploySettingsContext = createContext< +export const DeploySettingsContext = createContext< DeploySettingsContextValue | undefined >(undefined); @@ -29,6 +31,18 @@ export const useDeploySettings = (): DeploySettingsContextValue => { }; export const DeploySettingsLayout: FC = () => { + const { experiments } = useDashboard(); + + const multiOrgExperimentEnabled = experiments.includes("multi-organization"); + + return multiOrgExperimentEnabled ? ( + + ) : ( + + ); +}; + +const DeploySettingsLayoutInner: FC = () => { const deploymentConfigQuery = useQuery(deploymentConfig()); const { permissions } = useAuthenticated(); diff --git a/site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx new file mode 100644 index 0000000000000..4fbbab40dc29e --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx @@ -0,0 +1,30 @@ +import type { FC } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { createOrganization } from "api/queries/organizations"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { CreateOrganizationPageView } from "./CreateOrganizationPageView"; + +const CreateOrganizationPage: FC = () => { + const navigate = useNavigate(); + + const queryClient = useQueryClient(); + const createOrganizationMutation = useMutation( + createOrganization(queryClient), + ); + + const error = createOrganizationMutation.error; + + return ( + { + await createOrganizationMutation.mutateAsync(values); + displaySuccess("Organization created."); + navigate(`/organizations/${values.name}`); + }} + /> + ); +}; + +export default CreateOrganizationPage; diff --git a/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.stories.tsx new file mode 100644 index 0000000000000..81cad38a407ea --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { mockApiError } from "testHelpers/entities"; +import { CreateOrganizationPageView } from "./CreateOrganizationPageView"; + +const meta: Meta = { + title: "pages/CreateOrganizationPageView", + component: CreateOrganizationPageView, +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = {}; + +export const Error: Story = { + args: { error: "Oh no!" }, +}; + +export const InvalidName: Story = { + args: { + error: mockApiError({ + message: "Display name is bad", + validations: [ + { + field: "display_name", + detail: "That display name is terrible. What were you thinking?", + }, + ], + }), + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx new file mode 100644 index 0000000000000..9b1c8632241ef --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx @@ -0,0 +1,112 @@ +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import type { FC } from "react"; +import * as Yup from "yup"; +import { isApiValidationError } from "api/errors"; +import type { CreateOrganizationRequest } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { + FormFields, + FormSection, + HorizontalForm, + FormFooter, +} from "components/Form/Form"; +import { IconField } from "components/IconField/IconField"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { + getFormHelpers, + nameValidator, + displayNameValidator, + onChangeTrimmed, +} from "utils/formUtils"; + +const MAX_DESCRIPTION_CHAR_LIMIT = 128; +const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; + +const validationSchema = Yup.object({ + name: nameValidator("Name"), + display_name: displayNameValidator("Display name"), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + MAX_DESCRIPTION_MESSAGE, + ), +}); + +interface CreateOrganizationPageViewProps { + error: unknown; + onSubmit: (values: CreateOrganizationRequest) => Promise; +} + +export const CreateOrganizationPageView: FC< + CreateOrganizationPageViewProps +> = ({ error, onSubmit }) => { + const form = useFormik({ + initialValues: { + name: "", + display_name: "", + description: "", + icon: "", + }, + validationSchema, + onSubmit, + }); + const getFieldHelpers = getFormHelpers(form, error); + + return ( +
+ + Organization settings + + + {Boolean(error) && !isApiValidationError(error) && ( +
+ +
+ )} + + + +
+ + + + + form.setFieldValue("icon", value)} + /> + +
+
+ +
+
+ ); +}; diff --git a/site/src/pages/ManagementSettingsPage/Horizontal.tsx b/site/src/pages/ManagementSettingsPage/Horizontal.tsx new file mode 100644 index 0000000000000..ff6ddba89f0eb --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/Horizontal.tsx @@ -0,0 +1,88 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { FC, HTMLAttributes, ReactNode } from "react"; + +export const HorizontalContainer: FC> = ({ + ...attrs +}) => { + return
; +}; + +interface HorizontalSectionProps + extends Omit, "title"> { + title: ReactNode; + description: ReactNode; + children?: ReactNode; +} + +export const HorizontalSection: FC = ({ + children, + title, + description, + ...attrs +}) => { + return ( +
+
+

{title}

+
{description}
+
+ + {children} +
+ ); +}; + +const styles = { + horizontalContainer: (theme) => ({ + display: "flex", + flexDirection: "column", + gap: 80, + + [theme.breakpoints.down("md")]: { + gap: 64, + }, + }), + + formSection: (theme) => ({ + display: "flex", + flexDirection: "row", + gap: 120, + + [theme.breakpoints.down("lg")]: { + flexDirection: "column", + gap: 16, + }, + }), + + formSectionInfo: (theme) => ({ + width: "100%", + flexShrink: 0, + top: 24, + maxWidth: 312, + position: "sticky", + + [theme.breakpoints.down("md")]: { + width: "100%", + position: "initial", + }, + }), + + formSectionInfoTitle: (theme) => ({ + fontSize: 20, + color: theme.palette.text.primary, + fontWeight: 400, + margin: 0, + marginBottom: 8, + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 12, + }), + + formSectionInfoDescription: (theme) => ({ + fontSize: 14, + color: theme.palette.text.secondary, + lineHeight: "160%", + margin: 0, + }), +} satisfies Record>; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx similarity index 61% rename from site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx rename to site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx index ae278b053428a..9eace9c20f2bd 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx +++ b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx @@ -1,6 +1,7 @@ import { createContext, type FC, Suspense, useContext } from "react"; import { useQuery } from "react-query"; -import { Outlet, useParams } from "react-router-dom"; +import { Outlet, useLocation, useParams } from "react-router-dom"; +import { deploymentConfig } from "api/queries/deployment"; import { myOrganizations } from "api/queries/users"; import type { Organization } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; @@ -10,10 +11,11 @@ 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 { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayout"; import { Sidebar } from "./Sidebar"; type OrganizationSettingsContextValue = { - currentOrganizationId: string; + currentOrganizationId?: string; organizations: Organization[]; }; @@ -27,18 +29,25 @@ export const useOrganizationSettings = (): OrganizationSettingsContextValue => { throw new Error( "useOrganizationSettings should be used inside of OrganizationSettingsLayout", ); + return { organizations: [] }; } return context; }; -export const OrganizationSettingsLayout: FC = () => { +export const ManagementSettingsLayout: FC = () => { + const location = useLocation(); const { permissions, organizationIds } = useAuthenticated(); const { experiments } = useDashboard(); const { organization } = useParams() as { organization: string }; + const deploymentConfigQuery = useQuery(deploymentConfig()); const organizationsQuery = useQuery(myOrganizations()); const multiOrgExperimentEnabled = experiments.includes("multi-organization"); + const inOrganizationSettings = + location.pathname.startsWith("/organizations") && + location.pathname !== "/organizations/new"; + if (!multiOrgExperimentEnabled) { return ; } @@ -50,18 +59,31 @@ export const OrganizationSettingsLayout: FC = () => { {organizationsQuery.data ? ( org.name === organization, - )?.id ?? organizationIds[0], + currentOrganizationId: !inOrganizationSettings + ? undefined + : !organization + ? organizationIds[0] + : organizationsQuery.data.find( + (org) => org.name === organization, + )?.id, organizations: organizationsQuery.data, }} >
- }> - - + {deploymentConfigQuery.data ? ( + + }> + + + + ) : ( + + )}
) : ( diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx new file mode 100644 index 0000000000000..959d206c2e163 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx @@ -0,0 +1,55 @@ +import type { FC } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { + updateOrganization, + deleteOrganization, +} from "api/queries/organizations"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { useOrganizationSettings } from "./ManagementSettingsLayout"; +import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; + +const OrganizationSettingsPage: FC = () => { + const navigate = useNavigate(); + + const queryClient = useQueryClient(); + 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 ?? deleteOrganizationMutation.error; + + if (!currentOrganizationId || !org) { + return ; + } + + return ( + { + await updateOrganizationMutation.mutateAsync({ + orgId: org.id, + req: values, + }); + displaySuccess("Organization settings updated."); + }} + onDeleteOrganization={() => { + deleteOrganizationMutation.mutate(org.id); + displaySuccess("Organization deleted."); + navigate("/organizations"); + }} + /> + ); +}; + +export default OrganizationSettingsPage; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.stories.tsx new file mode 100644 index 0000000000000..37ce1185d7dba --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockDefaultOrganization, + MockOrganization, +} from "testHelpers/entities"; +import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; + +const meta: Meta = { + title: "pages/OrganizationSettingsPageView", + component: OrganizationSettingsPageView, + args: { + organization: MockOrganization, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = {}; + +export const DefaultOrg: Story = { + args: { + organization: MockDefaultOrganization, + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx new file mode 100644 index 0000000000000..0a07a2611183a --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx @@ -0,0 +1,194 @@ +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 * as Yup from "yup"; +import { isApiValidationError } from "api/errors"; +import type { + Organization, + UpdateOrganizationRequest, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { + FormFields, + FormSection, + HorizontalForm, + FormFooter, +} from "components/Form/Form"; +import { IconField } from "components/IconField/IconField"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { + getFormHelpers, + nameValidator, + displayNameValidator, + onChangeTrimmed, +} from "utils/formUtils"; +import { HorizontalContainer, HorizontalSection } from "./Horizontal"; + +const MAX_DESCRIPTION_CHAR_LIMIT = 128; +const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; + +const validationSchema = Yup.object({ + name: nameValidator("Name"), + display_name: displayNameValidator("Display name"), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + MAX_DESCRIPTION_MESSAGE, + ), +}); + +interface OrganizationSettingsPageViewProps { + organization: Organization; + error: unknown; + onSubmit: (values: UpdateOrganizationRequest) => Promise; + onDeleteOrganization: () => void; +} + +export const OrganizationSettingsPageView: FC< + OrganizationSettingsPageViewProps +> = ({ organization, error, onSubmit, onDeleteOrganization }) => { + const form = useFormik({ + initialValues: { + name: organization.name, + display_name: organization.display_name, + description: organization.description, + icon: organization.icon, + }, + validationSchema, + onSubmit, + enableReinitialize: true, + }); + const getFieldHelpers = getFormHelpers(form, error); + + const [isDeleting, setIsDeleting] = useState(false); + + return ( +
+ + Organization settings + + + {Boolean(error) && !isApiValidationError(error) && ( +
+ +
+ )} + + + +
+ + + + + form.setFieldValue("icon", value)} + /> + +
+
+ +
+ + {!organization.is_default && ( + + +
+ Deleting an organization is irreversible. + +
+
+
+ )} + + setIsDeleting(false)} + entity="organization" + name={organization.name} + /> +
+ ); +}; + +const styles = { + dangerSettings: (theme) => ({ + display: "flex", + backgroundColor: theme.roles.danger.background, + alignItems: "center", + justifyContent: "space-between", + border: `1px solid ${theme.roles.danger.outline}`, + borderRadius: 8, + padding: 12, + paddingLeft: 18, + gap: 8, + lineHeight: "18px", + flexGrow: 1, + + "& .option": { + color: theme.roles.danger.fill.solid, + "&.Mui-checked": { + color: theme.roles.danger.fill.solid, + }, + }, + + "& .info": { + fontSize: 14, + fontWeight: 600, + color: theme.roles.danger.text, + }, + }), + dangerButton: (theme) => ({ + borderColor: theme.roles.danger.outline, + color: theme.roles.danger.text, + + "&:not(.MuiLoadingButton-loading)": { + color: theme.roles.danger.fill.text, + }, + + "&:hover:not(:disabled)": { + backgroundColor: theme.roles.danger.hover.background, + borderColor: theme.roles.danger.hover.fill.outline, + }, + }), +} satisfies Record>; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPlaceholder.tsx similarity index 93% rename from site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx rename to site/src/pages/ManagementSettingsPage/OrganizationSettingsPlaceholder.tsx index d0b3d95bc894c..a1526ed53c102 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPlaceholder.tsx @@ -6,7 +6,7 @@ import { } from "api/queries/organizations"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Margins } from "components/Margins/Margins"; -import { useOrganizationSettings } from "./OrganizationSettingsLayout"; +import { useOrganizationSettings } from "./ManagementSettingsLayout"; const OrganizationSettingsPage: FC = () => { const queryClient = useQueryClient(); diff --git a/site/src/pages/OrganizationSettingsPage/Sidebar.tsx b/site/src/pages/ManagementSettingsPage/Sidebar.tsx similarity index 56% rename from site/src/pages/OrganizationSettingsPage/Sidebar.tsx rename to site/src/pages/ManagementSettingsPage/Sidebar.tsx index 20b45d44de344..dde4ef35664bd 100644 --- a/site/src/pages/OrganizationSettingsPage/Sidebar.tsx +++ b/site/src/pages/ManagementSettingsPage/Sidebar.tsx @@ -1,12 +1,15 @@ import { cx } from "@emotion/css"; +import type { Interpolation, Theme } from "@emotion/react"; +import AddIcon from "@mui/icons-material/Add"; +import SettingsIcon from "@mui/icons-material/Settings"; import type { FC, ReactNode } from "react"; -import { Link, NavLink } from "react-router-dom"; +import { Link, NavLink, useLocation } 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"; +import { useOrganizationSettings } from "./ManagementSettingsLayout"; export const Sidebar: FC = () => { const { currentOrganizationId, organizations } = useOrganizationSettings(); @@ -15,6 +18,16 @@ export const Sidebar: FC = () => { return ( +
Deployment
+ +
Organizations
+ } + > + New organization + {organizations.map((organization) => ( { ); }; -interface BloobProps { - organization: Organization; - active: boolean; -} +const DeploymentSettingsNavigation: FC = () => { + const location = useLocation(); + const active = location.pathname.startsWith("/deployment"); + + return ( +
+ } + > + Deployment + + {active && ( + + General + Licenses + Appearance + + User Authentication + + + External Authentication + + {/* Not exposing this yet since token exchange is not finished yet. + Network + + Workspace Proxies + + Security + + Observability + + Users + Groups + + )} +
+ ); +}; function urlForSubpage(organizationName: string, subpage: string = ""): string { return `/organizations/${organizationName}/${subpage}`; } -export const OrganizationSettingsNavigation: FC = ({ - organization, - active, -}) => { +interface OrganizationSettingsNavigationProps { + organization: Organization; + active: boolean; +} + +export const OrganizationSettingsNavigation: FC< + OrganizationSettingsNavigationProps +> = ({ organization, active }) => { return ( <> = ({ }; interface SidebarNavItemProps { - active?: boolean; + active?: boolean | "auto"; children?: ReactNode; - icon: ReactNode; + icon?: ReactNode; href: string; } @@ -101,12 +159,27 @@ export const SidebarNavItem: FC = ({ const link = useClassName(classNames.link, []); const activeLink = useClassName(classNames.activeLink, []); + const content = ( + + {icon} + {children} + + ); + + if (active === "auto") { + return ( + cx([link, isActive && activeLink])} + > + {content} + + ); + } + return ( - - {icon} - {children} - + {content} ); }; @@ -134,6 +207,16 @@ export const SidebarNavSubItem: FC = ({ ); }; +const styles = { + sidebarHeader: { + textTransform: "uppercase", + letterSpacing: "0.15em", + fontSize: 11, + fontWeight: 500, + paddingBottom: 4, + }, +} satisfies Record>; + const classNames = { link: (css, theme) => css` color: inherit; @@ -164,7 +247,7 @@ const classNames = { display: block; font-size: 13px; - margin-left: 42px; + margin-left: 44px; padding: 4px 12px; border-radius: 4px; transition: background-color 0.15s ease-in-out; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx deleted file mode 100644 index bc278b79c7e42..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ /dev/null @@ -1,192 +0,0 @@ -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/router.tsx b/site/src/router.tsx index e2685c29f69c8..e57176af28a84 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -13,7 +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 { ManagementSettingsLayout } from "./pages/ManagementSettingsPage/ManagementSettingsLayout"; import { SetupPage } from "./pages/SetupPage/SetupPage"; import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout"; import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout"; @@ -221,12 +221,15 @@ const AddNewLicensePage = lazy( () => import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"), ); +const CreateOrganizationPage = lazy( + () => import("./pages/ManagementSettingsPage/CreateOrganizationPage"), +); const OrganizationSettingsPage = lazy( - () => import("./pages/OrganizationSettingsPage/OrganizationSettingsPage"), + () => import("./pages/ManagementSettingsPage/OrganizationSettingsPage"), ); const OrganizationSettingsPlaceholder = lazy( () => - import("./pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder"), + import("./pages/ManagementSettingsPage/OrganizationSettingsPlaceholder"), ); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), @@ -333,31 +336,35 @@ export const router = createBrowserRouter( } /> - } - > + }> + } /> + + {/* General settings for the default org can omit the organization name */} } /> - } - /> - } - /> - } - /> - } - /> - } - /> + + + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + }> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 461942843d127..e00756051b331 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -13,12 +13,17 @@ import type { TemplateVersionFiles } from "utils/templateVersion"; export const MockOrganization: TypesGen.Organization = { id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", - name: "test-organization", - display_name: "Test Organization", - description: "", - icon: "", + name: "my-organization", + display_name: "My Organization", + description: "An organization that gets used for stuff.", + icon: "/emojis/1f957.png", created_at: "", updated_at: "", + is_default: false, +}; + +export const MockDefaultOrganization: TypesGen.Organization = { + ...MockOrganization, is_default: true, }; diff --git a/site/src/theme/mui.ts b/site/src/theme/mui.ts index 555aa23e641bc..3b4a3e86401ed 100644 --- a/site/src/theme/mui.ts +++ b/site/src/theme/mui.ts @@ -46,6 +46,13 @@ export const components = { ::placeholder { color: ${theme.palette.text.disabled}; } + + fieldset { + border: unset; + padding: 0; + margin: 0; + width: 100%; + } `, }, MuiAvatar: {