mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: unify organization and deployment management settings (#13602)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>>;
|
||||
+32
-10
@@ -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>>;
|
||||
+1
-1
@@ -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();
|
||||
+100
-17
@@ -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
@@ -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 />}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ export const components = {
|
||||
::placeholder {
|
||||
color: ${theme.palette.text.disabled};
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: unset;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
},
|
||||
MuiAvatar: {
|
||||
|
||||
Reference in New Issue
Block a user