feat: unify organization and deployment management settings (#13602)

This commit is contained in:
Kayla Washburn-Love
2024-07-01 11:15:00 -06:00
committed by GitHub
parent 9b1d8f79ae
commit d977654f05
19 changed files with 782 additions and 254 deletions
+11
View File
@@ -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,
+1 -1
View File
@@ -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",
+36
View File
@@ -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();
});
+1 -1
View File
@@ -52,7 +52,7 @@ export const Alert: FC<AlertProps> = ({
size="small"
onClick={() => {
setOpen(false);
onDismiss && onDismiss();
onDismiss?.();
}}
data-testid="dismiss-banner-btn"
>
@@ -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 ? (
<ManagementSettingsLayout />
) : (
<DeploySettingsLayoutInner />
);
};
const DeploySettingsLayoutInner: FC = () => {
const deploymentConfigQuery = useQuery(deploymentConfig());
const { permissions } = useAuthenticated();
@@ -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 (
<CreateOrganizationPageView
error={error}
onSubmit={async (values) => {
await createOrganizationMutation.mutateAsync(values);
displaySuccess("Organization created.");
navigate(`/organizations/${values.name}`);
}}
/>
);
};
export default CreateOrganizationPage;
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react";
import { mockApiError } from "testHelpers/entities";
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";
const meta: Meta<typeof CreateOrganizationPageView> = {
title: "pages/CreateOrganizationPageView",
component: CreateOrganizationPageView,
};
export default meta;
type Story = StoryObj<typeof CreateOrganizationPageView>;
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?",
},
],
}),
},
};
@@ -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<void>;
}
export const CreateOrganizationPageView: FC<
CreateOrganizationPageViewProps
> = ({ error, onSubmit }) => {
const form = useFormik<CreateOrganizationRequest>({
initialValues: {
name: "",
display_name: "",
description: "",
icon: "",
},
validationSchema,
onSubmit,
});
const getFieldHelpers = getFormHelpers(form, error);
return (
<div>
<PageHeader>
<PageHeaderTitle>Organization settings</PageHeaderTitle>
</PageHeader>
{Boolean(error) && !isApiValidationError(error) && (
<div css={{ marginBottom: 32 }}>
<ErrorAlert error={error} />
</div>
)}
<HorizontalForm
onSubmit={form.handleSubmit}
aria-label="Organization settings form"
>
<FormSection
title="General info"
description="Change the name or description of the organization."
>
<fieldset
disabled={form.isSubmitting}
css={{ border: "unset", padding: 0, margin: 0, width: "100%" }}
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label="Name"
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
fullWidth
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
</HorizontalForm>
</div>
);
};
@@ -0,0 +1,88 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC, HTMLAttributes, ReactNode } from "react";
export const HorizontalContainer: FC<HTMLAttributes<HTMLDivElement>> = ({
...attrs
}) => {
return <div css={styles.horizontalContainer} {...attrs} />;
};
interface HorizontalSectionProps
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
title: ReactNode;
description: ReactNode;
children?: ReactNode;
}
export const HorizontalSection: FC<HorizontalSectionProps> = ({
children,
title,
description,
...attrs
}) => {
return (
<section css={styles.formSection} {...attrs}>
<div css={styles.formSectionInfo}>
<h2 css={styles.formSectionInfoTitle}>{title}</h2>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
{children}
</section>
);
};
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<string, Interpolation<Theme>>;
@@ -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 <NotFoundPage />;
}
@@ -50,18 +59,31 @@ export const OrganizationSettingsLayout: FC = () => {
{organizationsQuery.data ? (
<OrganizationSettingsContext.Provider
value={{
currentOrganizationId:
organizationsQuery.data.find(
(org) => org.name === organization,
)?.id ?? organizationIds[0],
currentOrganizationId: !inOrganizationSettings
? undefined
: !organization
? organizationIds[0]
: organizationsQuery.data.find(
(org) => org.name === organization,
)?.id,
organizations: organizationsQuery.data,
}}
>
<Sidebar />
<main css={{ width: "100%" }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
{deploymentConfigQuery.data ? (
<DeploySettingsContext.Provider
value={{
deploymentValues: deploymentConfigQuery.data,
}}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</DeploySettingsContext.Provider>
) : (
<Loader />
)}
</main>
</OrganizationSettingsContext.Provider>
) : (
@@ -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 <EmptyState message="Organization not found" />;
}
return (
<OrganizationSettingsPageView
organization={org}
error={error}
onSubmit={async (values) => {
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;
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockDefaultOrganization,
MockOrganization,
} from "testHelpers/entities";
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
const meta: Meta<typeof OrganizationSettingsPageView> = {
title: "pages/OrganizationSettingsPageView",
component: OrganizationSettingsPageView,
args: {
organization: MockOrganization,
},
};
export default meta;
type Story = StoryObj<typeof OrganizationSettingsPageView>;
export const Example: Story = {};
export const DefaultOrg: Story = {
args: {
organization: MockDefaultOrganization,
},
};
@@ -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<void>;
onDeleteOrganization: () => void;
}
export const OrganizationSettingsPageView: FC<
OrganizationSettingsPageViewProps
> = ({ organization, error, onSubmit, onDeleteOrganization }) => {
const form = useFormik<UpdateOrganizationRequest>({
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 (
<div>
<PageHeader>
<PageHeaderTitle>Organization settings</PageHeaderTitle>
</PageHeader>
{Boolean(error) && !isApiValidationError(error) && (
<div css={{ marginBottom: 32 }}>
<ErrorAlert error={error} />
</div>
)}
<HorizontalForm
onSubmit={form.handleSubmit}
aria-label="Organization settings form"
>
<FormSection
title="Info"
description="Change the name or description of the organization."
>
<fieldset
disabled={form.isSubmitting}
css={{ border: "unset", padding: 0, margin: 0, width: "100%" }}
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label="Name"
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
fullWidth
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
</HorizontalForm>
{!organization.is_default && (
<HorizontalContainer css={{ marginTop: 48 }}>
<HorizontalSection
title="Settings"
description="Change or delete your organization."
>
<div css={styles.dangerSettings}>
<span>Deleting an organization is irreversible.</span>
<Button
css={styles.dangerButton}
color="warning"
onClick={() => setIsDeleting(true)}
>
Delete this organization
</Button>
</div>
</HorizontalSection>
</HorizontalContainer>
)}
<DeleteDialog
isOpen={isDeleting}
onConfirm={onDeleteOrganization}
onCancel={() => setIsDeleting(false)}
entity="organization"
name={organization.name}
/>
</div>
);
};
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<string, Interpolation<Theme>>;
@@ -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();
@@ -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 (
<BaseSidebar>
<header css={styles.sidebarHeader}>Deployment</header>
<DeploymentSettingsNavigation />
<header css={styles.sidebarHeader}>Organizations</header>
<SidebarNavItem
active="auto"
href="/organizations/new"
icon={<AddIcon />}
>
New organization
</SidebarNavItem>
{organizations.map((organization) => (
<OrganizationSettingsNavigation
key={organization.id}
@@ -26,19 +39,64 @@ export const Sidebar: FC = () => {
);
};
interface BloobProps {
organization: Organization;
active: boolean;
}
const DeploymentSettingsNavigation: FC = () => {
const location = useLocation();
const active = location.pathname.startsWith("/deployment");
return (
<div css={{ paddingBottom: 12 }}>
<SidebarNavItem
active={active}
href="/deployment/general"
// 24px matches the width of the organization icons, and the component is smart enough
// to keep the icon itself square. It looks too big if it's 24x24.
icon={<SettingsIcon css={{ width: 24, height: 20 }} />}
>
Deployment
</SidebarNavItem>
{active && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
<SidebarNavSubItem href="general">General</SidebarNavSubItem>
<SidebarNavSubItem href="licenses">Licenses</SidebarNavSubItem>
<SidebarNavSubItem href="appearance">Appearance</SidebarNavSubItem>
<SidebarNavSubItem href="userauth">
User Authentication
</SidebarNavSubItem>
<SidebarNavSubItem href="external-auth">
External Authentication
</SidebarNavSubItem>
{/* Not exposing this yet since token exchange is not finished yet.
<SidebarNavSubItem href="oauth2-provider/ap>
OAuth2 Applications
</SidebarNavSubItem>*/}
<SidebarNavSubItem href="network">Network</SidebarNavSubItem>
<SidebarNavSubItem href="workspace-proxies">
Workspace Proxies
</SidebarNavSubItem>
<SidebarNavSubItem href="security">Security</SidebarNavSubItem>
<SidebarNavSubItem href="observability">
Observability
</SidebarNavSubItem>
<SidebarNavSubItem href="/users">Users</SidebarNavSubItem>
<SidebarNavSubItem href="/groups">Groups</SidebarNavSubItem>
</Stack>
)}
</div>
);
};
function urlForSubpage(organizationName: string, subpage: string = ""): string {
return `/organizations/${organizationName}/${subpage}`;
}
export const OrganizationSettingsNavigation: FC<BloobProps> = ({
organization,
active,
}) => {
interface OrganizationSettingsNavigationProps {
organization: Organization;
active: boolean;
}
export const OrganizationSettingsNavigation: FC<
OrganizationSettingsNavigationProps
> = ({ organization, active }) => {
return (
<>
<SidebarNavItem
@@ -86,9 +144,9 @@ export const OrganizationSettingsNavigation: FC<BloobProps> = ({
};
interface SidebarNavItemProps {
active?: boolean;
active?: boolean | "auto";
children?: ReactNode;
icon: ReactNode;
icon?: ReactNode;
href: string;
}
@@ -101,12 +159,27 @@ export const SidebarNavItem: FC<SidebarNavItemProps> = ({
const link = useClassName(classNames.link, []);
const activeLink = useClassName(classNames.activeLink, []);
const content = (
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
);
if (active === "auto") {
return (
<NavLink
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{content}
</NavLink>
);
}
return (
<Link to={href} className={cx([link, active && activeLink])}>
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
{content}
</Link>
);
};
@@ -134,6 +207,16 @@ export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({
);
};
const styles = {
sidebarHeader: {
textTransform: "uppercase",
letterSpacing: "0.15em",
fontSize: 11,
fontWeight: 500,
paddingBottom: 4,
},
} satisfies Record<string, Interpolation<Theme>>;
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;
@@ -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<UpdateOrganizationRequest>({
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 (
<Margins css={{ marginTop: 18, marginBottom: 18 }}>
{Boolean(error) && <ErrorAlert error={error} />}
<PageHeader css={{ paddingTop: error ? undefined : 0 }}>
<PageHeaderTitle>Organization settings</PageHeaderTitle>
</PageHeader>
<HorizontalForm
onSubmit={form.handleSubmit}
aria-label="Template settings form"
>
<FormSection
title="General info"
description="Change the name or description of the organization."
>
<fieldset
disabled={form.isSubmitting}
css={{ border: "unset", padding: 0, margin: 0, width: "100%" }}
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label="Name"
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
fullWidth
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
</HorizontalForm>
{!org.is_default && (
<Button
css={styles.dangerButton}
variant="contained"
onClick={() =>
deleteOrganizationMutation.mutate(currentOrganizationId)
}
>
Delete this organization
</Button>
)}
<Stack css={{ marginTop: 128 }}>
<TextField
label="New organization name"
onChange={(event) => setNewOrgName(event.target.value)}
/>
<Button
onClick={() => addOrganizationMutation.mutate({ name: newOrgName })}
>
Create new organization
</Button>
</Stack>
</Margins>
);
};
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<string, Interpolation<Theme>>;
+34 -27
View File
@@ -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(
<Route path="/audit" element={<AuditPage />} />
<Route
path="/organizations/:organization?"
element={<OrganizationSettingsLayout />}
>
<Route path="/organizations" element={<ManagementSettingsLayout />}>
<Route path="new" element={<CreateOrganizationPage />} />
{/* General settings for the default org can omit the organization name */}
<Route index element={<OrganizationSettingsPage />} />
<Route
path="external-auth"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="members"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="groups"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="metrics"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="auditing"
element={<OrganizationSettingsPlaceholder />}
/>
<Route path=":organization">
<Route index element={<OrganizationSettingsPage />} />
<Route
path="external-auth"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="members"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="groups"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="metrics"
element={<OrganizationSettingsPlaceholder />}
/>
<Route
path="auditing"
element={<OrganizationSettingsPlaceholder />}
/>
</Route>
</Route>
<Route path="/deployment" element={<DeploySettingsLayout />}>
+9 -4
View File
@@ -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,
};
+7
View File
@@ -46,6 +46,13 @@ export const components = {
::placeholder {
color: ${theme.palette.text.disabled};
}
fieldset {
border: unset;
padding: 0;
margin: 0;
width: 100%;
}
`,
},
MuiAvatar: {