feat(site): add basic organization management ui (#13288)

This commit is contained in:
Kayla Washburn-Love
2024-06-17 11:02:39 -06:00
committed by GitHub
parent 07cd9acb2c
commit 8c1bd32c33
32 changed files with 743 additions and 187 deletions

View File

@ -66,7 +66,7 @@ type OrganizationMemberWithName struct {
type CreateOrganizationRequest struct {
Name string `json:"name" validate:"required,organization_name"`
// DisplayName will default to the same value as `Name` if not provided.
DisplayName string `json:"display_name" validate:"omitempty,organization_display_name"`
DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
}

View File

@ -505,6 +505,31 @@ class ApiMethods {
return response.data;
};
createOrganization = async (params: TypesGen.CreateOrganizationRequest) => {
const response = await this.axios.post<TypesGen.Organization>(
"/api/v2/organizations",
params,
);
return response.data;
};
updateOrganization = async (
orgId: string,
params: TypesGen.UpdateOrganizationRequest,
) => {
const response = await this.axios.patch<TypesGen.Organization>(
`/api/v2/organizations/${orgId}`,
params,
);
return response.data;
};
deleteOrganization = async (orgId: string) => {
await this.axios.delete<TypesGen.Organization>(
`/api/v2/organizations/${orgId}`,
);
};
getOrganization = async (
organizationId: string,
): Promise<TypesGen.Organization> => {

View File

@ -0,0 +1,46 @@
import type { QueryClient } from "react-query";
import { API } from "api/api";
import type {
CreateOrganizationRequest,
UpdateOrganizationRequest,
} from "api/typesGenerated";
import { meKey, myOrganizationsKey } from "./users";
export const createOrganization = (queryClient: QueryClient) => {
return {
mutationFn: (params: CreateOrganizationRequest) =>
API.createOrganization(params),
onSuccess: async () => {
await queryClient.invalidateQueries(meKey);
await queryClient.invalidateQueries(myOrganizationsKey);
},
};
};
interface UpdateOrganizationVariables {
orgId: string;
req: UpdateOrganizationRequest;
}
export const updateOrganization = (queryClient: QueryClient) => {
return {
mutationFn: (variables: UpdateOrganizationVariables) =>
API.updateOrganization(variables.orgId, variables.req),
onSuccess: async () => {
await queryClient.invalidateQueries(myOrganizationsKey);
},
};
};
export const deleteOrganization = (queryClient: QueryClient) => {
return {
mutationFn: (orgId: string) => API.deleteOrganization(orgId),
onSuccess: async () => {
await queryClient.invalidateQueries(meKey);
await queryClient.invalidateQueries(myOrganizationsKey);
},
};
};

View File

@ -124,7 +124,7 @@ export const authMethods = () => {
};
};
const meKey = ["me"];
export const meKey = ["me"];
export const me = (metadata: MetadataState<User>) => {
return cachedQuery({
@ -250,9 +250,11 @@ export const updateAppearanceSettings = (
};
};
export const myOrganizationsKey = ["organizations", "me"] as const;
export const myOrganizations = () => {
return {
queryKey: ["organizations", "me"],
queryKey: myOrganizationsKey,
queryFn: () => API.getOrganizations(),
};
};

View File

@ -226,7 +226,7 @@ export interface CreateGroupRequest {
// From codersdk/organizations.go
export interface CreateOrganizationRequest {
readonly name: string;
readonly display_name: string;
readonly display_name?: string;
readonly description?: string;
readonly icon?: string;
}

View File

@ -1,23 +1,31 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { FormFooter } from "./FormFooter";
const meta: Meta<typeof FormFooter> = {
title: "components/FormFooter",
component: FormFooter,
args: {
isLoading: false,
onCancel: action("onCancel"),
},
};
export default meta;
type Story = StoryObj<typeof FormFooter>;
export const Ready: Story = {
args: {},
};
export const NoCancel: Story = {
args: {
isLoading: false,
onCancel: undefined,
},
};
export const Custom: Story = {
args: {
isLoading: false,
submitLabel: "Create",
},
};

View File

@ -14,7 +14,7 @@ export interface FormFooterStyles {
}
export interface FormFooterProps {
onCancel: () => void;
onCancel?: () => void;
isLoading: boolean;
styles?: FormFooterStyles;
submitLabel?: string;
@ -45,15 +45,17 @@ export const FormFooter: FC<FormFooterProps> = ({
>
{submitLabel}
</LoadingButton>
<Button
size="large"
type="button"
css={styles.button}
onClick={onCancel}
tabIndex={0}
>
{Language.cancelLabel}
</Button>
{onCancel && (
<Button
size="large"
type="button"
css={styles.button}
onClick={onCancel}
tabIndex={0}
>
{Language.cancelLabel}
</Button>
)}
{extraActions}
</div>
);

View File

@ -13,8 +13,13 @@ const widthBySize: Record<Size, number> = {
small: containerWidth / 3,
};
export const Margins: FC<JSX.IntrinsicElements["div"] & { size?: Size }> = ({
type MarginsProps = JSX.IntrinsicElements["div"] & {
size?: Size;
};
export const Margins: FC<MarginsProps> = ({
size = "regular",
children,
...divProps
}) => {
const maxWidth = widthBySize[size];
@ -22,11 +27,15 @@ export const Margins: FC<JSX.IntrinsicElements["div"] & { size?: Size }> = ({
<div
{...divProps}
css={{
margin: "0 auto",
marginLeft: "auto",
marginRight: "auto",
maxWidth: maxWidth,
padding: `0 ${sidePadding}px`,
paddingLeft: sidePadding,
paddingRight: sidePadding,
width: "100%",
}}
/>
>
{children}
</div>
);
};

View File

@ -13,22 +13,25 @@ import {
import { USERS_LINK } from "modules/navigation";
interface DeploymentDropdownProps {
canViewAuditLog: boolean;
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAllUsers: boolean;
canViewAuditLog: boolean;
canViewHealth: boolean;
}
export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewAuditLog,
canViewDeployment,
canViewOrganizations,
canViewAllUsers,
canViewAuditLog,
canViewHealth,
}) => {
const theme = useTheme();
if (
!canViewAuditLog &&
!canViewOrganizations &&
!canViewDeployment &&
!canViewAllUsers &&
!canViewHealth
@ -64,9 +67,10 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
}}
>
<DeploymentDropdownContent
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewOrganizations={canViewOrganizations}
canViewAllUsers={canViewAllUsers}
canViewAuditLog={canViewAuditLog}
canViewHealth={canViewHealth}
/>
</PopoverContent>
@ -75,9 +79,10 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
};
const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
canViewAuditLog,
canViewDeployment,
canViewOrganizations,
canViewAllUsers,
canViewAuditLog,
canViewHealth,
}) => {
const popover = usePopover();
@ -96,6 +101,16 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
Settings
</MenuItem>
)}
{canViewOrganizations && (
<MenuItem
component={NavLink}
to="/organizations"
css={styles.menuItem}
onClick={onPopoverClose}
>
Organizations
</MenuItem>
)}
{canViewAllUsers && (
<MenuItem
component={NavLink}

View File

@ -12,7 +12,7 @@ export const Navbar: FC = () => {
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const { appearance } = useDashboard();
const { appearance, experiments } = useDashboard();
const { user: me, permissions, signOut } = useAuthenticated();
const featureVisibility = useFeatureVisibility();
const canViewAuditLog =
@ -29,10 +29,11 @@ export const Navbar: FC = () => {
buildInfo={buildInfoQuery.data}
supportLinks={appearance.support_links}
onSignOut={signOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewOrganizations={experiments.includes("multi-organization")}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
canViewAuditLog={canViewAuditLog}
proxyContextValue={proxyContextValue}
/>
);

View File

@ -28,10 +28,11 @@ describe("NavbarView", () => {
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const workspacesLink = await screen.findByText(navLanguage.workspaces);
@ -44,10 +45,11 @@ describe("NavbarView", () => {
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const templatesLink = await screen.findByText(navLanguage.templates);
@ -60,10 +62,11 @@ describe("NavbarView", () => {
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const deploymentMenu = await screen.findByText("Deployment");
@ -78,10 +81,11 @@ describe("NavbarView", () => {
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const deploymentMenu = await screen.findByText("Deployment");
@ -96,10 +100,11 @@ describe("NavbarView", () => {
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const deploymentMenu = await screen.findByText("Deployment");

View File

@ -19,9 +19,10 @@ export interface NavbarViewProps {
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
canViewAuditLog: boolean;
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAllUsers: boolean;
canViewAuditLog: boolean;
canViewHealth: boolean;
proxyContextValue?: ProxyContextValue;
}
@ -69,10 +70,11 @@ export const NavbarView: FC<NavbarViewProps> = ({
buildInfo,
supportLinks,
onSignOut,
canViewAuditLog,
canViewDeployment,
canViewOrganizations,
canViewAllUsers,
canViewHealth,
canViewAuditLog,
proxyContextValue,
}) => {
const theme = useTheme();
@ -134,6 +136,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
<DeploymentDropdown
canViewAuditLog={canViewAuditLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}

View File

@ -1,8 +1,6 @@
import { css, type Interpolation, type Theme, useTheme } from "@emotion/react";
import Badge from "@mui/material/Badge";
import type { FC } from "react";
import { useQuery } from "react-query";
import { myOrganizations } from "api/queries/users";
import type * as TypesGen from "api/typesGenerated";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import {
@ -11,7 +9,6 @@ import {
PopoverTrigger,
} from "components/Popover/Popover";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { useDashboard } from "modules/dashboard/useDashboard";
import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants";
import { UserDropdownContent } from "./UserDropdownContent";
@ -29,11 +26,6 @@ export const UserDropdown: FC<UserDropdownProps> = ({
onSignOut,
}) => {
const theme = useTheme();
const organizationsQuery = useQuery({
...myOrganizations(),
enabled: Boolean(localStorage.getItem("enableMultiOrganizationUi")),
});
const { organizationId, setOrganizationId } = useDashboard();
return (
<Popover>
@ -71,9 +63,6 @@ export const UserDropdown: FC<UserDropdownProps> = ({
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
organizations={organizationsQuery.data}
organizationId={organizationId}
setOrganizationId={setOrganizationId}
onSignOut={onSignOut}
/>
</PopoverContent>

View File

@ -29,9 +29,6 @@ export const Language = {
export interface UserDropdownContentProps {
user: TypesGen.User;
organizations?: TypesGen.Organization[];
organizationId?: string;
setOrganizationId?: (id: string) => void;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
@ -39,9 +36,6 @@ export interface UserDropdownContentProps {
export const UserDropdownContent: FC<UserDropdownContentProps> = ({
user,
organizations,
organizationId,
setOrganizationId,
buildInfo,
supportLinks,
onSignOut,
@ -79,43 +73,6 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<Divider css={{ marginBottom: 8 }} />
{organizations && (
<>
<div>
<div
css={{
padding: "8px 20px 6px",
textTransform: "uppercase",
letterSpacing: 1.1,
lineHeight: 1.1,
fontSize: "0.8em",
}}
>
My teams
</div>
{organizations.map((org) => (
<MenuItem
key={org.id}
css={styles.menuItem}
onClick={() => {
setOrganizationId?.(org.id);
popover.setIsOpen(false);
}}
>
{/* <LogoutIcon css={styles.menuItemIcon} /> */}
<Stack direction="row" spacing={1} css={styles.menuItemText}>
{org.name}
{organizationId === org.id && (
<span css={{ fontSize: 12, color: "gray" }}>Current</span>
)}
</Stack>
</MenuItem>
))}
</div>
<Divider css={{ marginTop: 8, marginBottom: 8 }} />
</>
)}
<Link to="/settings/account" css={styles.link}>
<MenuItem css={styles.menuItem} onClick={onPopoverClose}>
<AccountIcon css={styles.menuItemIcon} />

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import {
MockTemplate,
@ -15,6 +16,7 @@ const meta: Meta<typeof CreateTemplateForm> = {
component: CreateTemplateForm,
args: {
isSubmitting: false,
onCancel: action("onCancel"),
},
};

View File

@ -25,7 +25,7 @@ import {
nameValidator,
getFormHelpers,
onChangeTrimmed,
templateDisplayNameValidator,
displayNameValidator,
} from "utils/formUtils";
import {
sortedDays,
@ -57,7 +57,7 @@ export interface CreateTemplateData {
const validationSchema = Yup.object({
name: nameValidator("Name"),
display_name: templateDisplayNameValidator("Display name"),
display_name: displayNameValidator("Display name"),
description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT,
"Please enter a description that is less than or equal to 128 characters.",

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { chromatic } from "testHelpers/chromatic";
import {
@ -26,6 +27,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
permissions: {
createWorkspaceForUser: true,
},
onCancel: action("onCancel"),
},
};

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { MockGroup } from "testHelpers/entities";
import { SettingsGroupPageView } from "./SettingsGroupPageView";
@ -5,16 +6,16 @@ import { SettingsGroupPageView } from "./SettingsGroupPageView";
const meta: Meta<typeof SettingsGroupPageView> = {
title: "pages/GroupsPage/SettingsGroupPageView",
component: SettingsGroupPageView,
};
export default meta;
type Story = StoryObj<typeof SettingsGroupPageView>;
const Example: Story = {
args: {
onCancel: action("onCancel"),
group: MockGroup,
isLoading: false,
},
};
export default meta;
type Story = StoryObj<typeof SettingsGroupPageView>;
const Example: Story = {};
export { Example as SettingsGroupPageView };

View File

@ -0,0 +1,74 @@
import { createContext, type FC, Suspense, useContext } from "react";
import { useQuery } from "react-query";
import { Outlet, useParams } from "react-router-dom";
import { myOrganizations } from "api/queries/users";
import type { Organization } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { RequirePermission } from "contexts/auth/RequirePermission";
import { useDashboard } from "modules/dashboard/useDashboard";
import NotFoundPage from "pages/404Page/404Page";
import { Sidebar } from "./Sidebar";
type OrganizationSettingsContextValue = {
currentOrganizationId: string;
organizations: Organization[];
};
const OrganizationSettingsContext = createContext<
OrganizationSettingsContextValue | undefined
>(undefined);
export const useOrganizationSettings = (): OrganizationSettingsContextValue => {
const context = useContext(OrganizationSettingsContext);
if (!context) {
throw new Error(
"useOrganizationSettings should be used inside of OrganizationSettingsLayout",
);
}
return context;
};
export const OrganizationSettingsLayout: FC = () => {
const { permissions, organizationIds } = useAuthenticated();
const { experiments } = useDashboard();
const { organization } = useParams() as { organization: string };
const organizationsQuery = useQuery(myOrganizations());
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
if (!multiOrgExperimentEnabled) {
return <NotFoundPage />;
}
return (
<RequirePermission isFeatureVisible={permissions.viewDeploymentValues}>
<Margins>
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
{organizationsQuery.data ? (
<OrganizationSettingsContext.Provider
value={{
currentOrganizationId:
organizationsQuery.data.find(
(org) => org.name === organization,
)?.id ?? organizationIds[0],
organizations: organizationsQuery.data,
}}
>
<Sidebar />
<main css={{ width: "100%" }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</OrganizationSettingsContext.Provider>
) : (
<Loader />
)}
</Stack>
</Margins>
</RequirePermission>
);
};

View File

@ -0,0 +1,192 @@
import type { Interpolation, Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { type FC, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import * as Yup from "yup";
import {
createOrganization,
updateOrganization,
deleteOrganization,
} from "api/queries/organizations";
import type { UpdateOrganizationRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import {
FormFields,
FormSection,
HorizontalForm,
FormFooter,
} from "components/Form/Form";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { IconField } from "components/IconField/IconField";
import { Margins } from "components/Margins/Margins";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import {
getFormHelpers,
nameValidator,
displayNameValidator,
onChangeTrimmed,
} from "utils/formUtils";
import { useOrganizationSettings } from "./OrganizationSettingsLayout";
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`;
export const validationSchema = Yup.object({
name: nameValidator("Name"),
display_name: displayNameValidator("Display name"),
description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT,
MAX_DESCRIPTION_MESSAGE,
),
});
const OrganizationSettingsPage: FC = () => {
const queryClient = useQueryClient();
const addOrganizationMutation = useMutation(createOrganization(queryClient));
const updateOrganizationMutation = useMutation(
updateOrganization(queryClient),
);
const deleteOrganizationMutation = useMutation(
deleteOrganization(queryClient),
);
const { currentOrganizationId, organizations } = useOrganizationSettings();
const org = organizations.find((org) => org.id === currentOrganizationId)!;
const error =
updateOrganizationMutation.error ??
addOrganizationMutation.error ??
deleteOrganizationMutation.error;
const form = useFormik<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>>;

View File

@ -0,0 +1,37 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import {
createOrganization,
deleteOrganization,
} from "api/queries/organizations";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Margins } from "components/Margins/Margins";
import { useOrganizationSettings } from "./OrganizationSettingsLayout";
const OrganizationSettingsPage: FC = () => {
const queryClient = useQueryClient();
const addOrganizationMutation = useMutation(createOrganization(queryClient));
const deleteOrganizationMutation = useMutation(
deleteOrganization(queryClient),
);
const { currentOrganizationId, organizations } = useOrganizationSettings();
const org = organizations.find((org) => org.id === currentOrganizationId)!;
const error =
addOrganizationMutation.error ?? deleteOrganizationMutation.error;
return (
<Margins css={{ marginTop: 48, marginBottom: 48 }}>
{Boolean(error) && <ErrorAlert error={error} />}
<h1>Organization settings</h1>
<p>Name: {org.name}</p>
<p>Display name: {org.display_name}</p>
</Margins>
);
};
export default OrganizationSettingsPage;

View File

@ -0,0 +1,182 @@
import { cx } from "@emotion/css";
import type { FC, ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import type { Organization } from "api/typesGenerated";
import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar";
import { Stack } from "components/Stack/Stack";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { type ClassName, useClassName } from "hooks/useClassName";
import { useOrganizationSettings } from "./OrganizationSettingsLayout";
export const Sidebar: FC = () => {
const { currentOrganizationId, organizations } = useOrganizationSettings();
// TODO: Do something nice to scroll to the active org.
return (
<BaseSidebar>
{organizations.map((organization) => (
<OrganizationSettingsNavigation
key={organization.id}
organization={organization}
active={organization.id === currentOrganizationId}
/>
))}
</BaseSidebar>
);
};
interface BloobProps {
organization: Organization;
active: boolean;
}
function urlForSubpage(organizationName: string, subpage: string = ""): string {
return `/organizations/${organizationName}/${subpage}`;
}
export const OrganizationSettingsNavigation: FC<BloobProps> = ({
organization,
active,
}) => {
return (
<>
<SidebarNavItem
active={active}
href={urlForSubpage(organization.name)}
icon={
<UserAvatar
key={organization.id}
size="sm"
username={organization.display_name}
avatarURL={organization.icon}
/>
}
>
{organization.display_name}
</SidebarNavItem>
{active && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
<SidebarNavSubItem href={urlForSubpage(organization.name)}>
Organization settings
</SidebarNavSubItem>
<SidebarNavSubItem
href={urlForSubpage(organization.name, "external-auth")}
>
External authentication
</SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "members")}>
Members
</SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "groups")}>
Groups
</SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "metrics")}>
Metrics
</SidebarNavSubItem>
<SidebarNavSubItem
href={urlForSubpage(organization.name, "auditing")}
>
Auditing
</SidebarNavSubItem>
</Stack>
)}
</>
);
};
interface SidebarNavItemProps {
active?: boolean;
children?: ReactNode;
icon: ReactNode;
href: string;
}
export const SidebarNavItem: FC<SidebarNavItemProps> = ({
active,
children,
href,
icon,
}) => {
const link = useClassName(classNames.link, []);
const activeLink = useClassName(classNames.activeLink, []);
return (
<Link to={href} className={cx([link, active && activeLink])}>
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
</Link>
);
};
interface SidebarNavSubItemProps {
children?: ReactNode;
href: string;
}
export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({
children,
href,
}) => {
const link = useClassName(classNames.subLink, []);
const activeLink = useClassName(classNames.activeSubLink, []);
return (
<NavLink
end
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{children}
</NavLink>
);
};
const classNames = {
link: (css, theme) => css`
color: inherit;
display: block;
font-size: 14px;
text-decoration: none;
padding: 10px 12px 10px 16px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
border-left: 3px solid transparent;
`,
activeLink: (css, theme) => css`
border-left-color: ${theme.palette.primary.main};
border-top-left-radius: 0;
border-bottom-left-radius: 0;
`,
subLink: (css, theme) => css`
color: inherit;
text-decoration: none;
display: block;
font-size: 13px;
margin-left: 42px;
padding: 4px 12px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
margin-bottom: 1px;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
`,
activeSubLink: (css) => css`
font-weight: 600;
`,
} satisfies Record<string, ClassName>;

View File

@ -3,7 +3,7 @@ import FormControlLabel from "@mui/material/FormControlLabel";
import FormHelperText from "@mui/material/FormHelperText";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { type FormikContextType, type FormikTouched, useFormik } from "formik";
import { type FormikTouched, useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import {
@ -27,29 +27,27 @@ import {
import {
getFormHelpers,
nameValidator,
templateDisplayNameValidator,
displayNameValidator,
onChangeTrimmed,
iconValidator,
} from "utils/formUtils";
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
const MAX_DESCRIPTION_MESSAGE =
"Please enter a description that is no longer than 128 characters.";
const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`;
export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({
name: nameValidator("Name"),
display_name: templateDisplayNameValidator("Display name"),
description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT,
MAX_DESCRIPTION_MESSAGE,
),
allow_user_cancel_workspace_jobs: Yup.boolean(),
icon: iconValidator,
require_active_version: Yup.boolean(),
deprecation_message: Yup.string(),
max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels),
});
export const validationSchema = Yup.object({
name: nameValidator("Name"),
display_name: displayNameValidator("Display name"),
description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT,
MAX_DESCRIPTION_MESSAGE,
),
allow_user_cancel_workspace_jobs: Yup.boolean(),
icon: iconValidator,
require_active_version: Yup.boolean(),
deprecation_message: Yup.string(),
max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels),
});
export interface TemplateSettingsForm {
template: Template;
@ -75,27 +73,25 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
advancedSchedulingEnabled,
portSharingControlsEnabled,
}) => {
const validationSchema = getValidationSchema();
const form: FormikContextType<UpdateTemplateMeta> =
useFormik<UpdateTemplateMeta>({
initialValues: {
name: template.name,
display_name: template.display_name,
description: template.description,
icon: template.icon,
allow_user_cancel_workspace_jobs:
template.allow_user_cancel_workspace_jobs,
update_workspace_last_used_at: false,
update_workspace_dormant_at: false,
require_active_version: template.require_active_version,
deprecation_message: template.deprecation_message,
disable_everyone_group_access: false,
max_port_share_level: template.max_port_share_level,
},
validationSchema,
onSubmit,
initialTouched,
});
const form = useFormik<UpdateTemplateMeta>({
initialValues: {
name: template.name,
display_name: template.display_name,
description: template.description,
icon: template.icon,
allow_user_cancel_workspace_jobs:
template.allow_user_cancel_workspace_jobs,
update_workspace_last_used_at: false,
update_workspace_dormant_at: false,
require_active_version: template.require_active_version,
deprecation_message: template.deprecation_message,
disable_everyone_group_access: false,
max_port_share_level: template.max_port_share_level,
},
validationSchema,
onSubmit,
initialTouched,
});
const getFieldHelpers = getFormHelpers(form, error);
return (

View File

@ -10,7 +10,7 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { getValidationSchema } from "./TemplateSettingsForm";
import { validationSchema } from "./TemplateSettingsForm";
import { TemplateSettingsPage } from "./TemplateSettingsPage";
type FormValues = Required<
@ -116,9 +116,9 @@ describe("TemplateSettingsPage", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
description:
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port",
"The quick brown fox jumps over the lazy dog repeatedly, enjoying the weather of the bright, summer day in the lush, scenic park.",
};
const validate = () => getValidationSchema().validateSync(values);
const validate = () => validationSchema.validateSync(values);
expect(validate).not.toThrowError();
});
@ -126,9 +126,9 @@ describe("TemplateSettingsPage", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
description:
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a",
"The quick brown fox jumps over the lazy dog multiple times, enjoying the warmth of the bright, sunny day in the lush, green park.",
};
const validate = () => getValidationSchema().validateSync(values);
const validate = () => validationSchema.validateSync(values);
expect(validate).toThrowError();
});

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { mockApiError, MockTemplate } from "testHelpers/entities";
import { TemplateSettingsPageView } from "./TemplateSettingsPageView";
@ -9,6 +10,7 @@ const meta: Meta<typeof TemplateSettingsPageView> = {
template: MockTemplate,
accessControlEnabled: true,
advancedSchedulingEnabled: true,
onCancel: action("onCancel"),
},
};

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import {
mockApiError,
@ -13,6 +14,9 @@ import { TemplateVariablesPageView } from "./TemplateVariablesPageView";
const meta: Meta<typeof TemplateVariablesPageView> = {
title: "pages/TemplateSettingsPage/TemplateVariablesPageView",
component: TemplateVariablesPageView,
args: {
onCancel: action("onCancel"),
},
};
export default meta;

View File

@ -1,8 +1,6 @@
import Button from "@mui/material/Button";
import { type FC, useEffect, useState } from "react";
import type { FC } from "react";
import { useQuery } from "react-query";
import { groupsForUser } from "api/queries/groups";
import { DisabledBadge, EnabledBadge } from "components/Badges/Badges";
import { Stack } from "components/Stack/Stack";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { useAuthenticated } from "contexts/auth/RequireAuth";
@ -15,7 +13,7 @@ export const AccountPage: FC = () => {
const { permissions, user: me } = useAuthenticated();
const { updateProfile, updateProfileError, isUpdatingProfile } =
useAuthContext();
const { entitlements, experiments, organizationId } = useDashboard();
const { entitlements, organizationId } = useDashboard();
const hasGroupsFeature = entitlements.features.user_role_management.enabled;
const groupsQuery = useQuery({
@ -23,21 +21,6 @@ export const AccountPage: FC = () => {
enabled: hasGroupsFeature,
});
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
const [multiOrgUiEnabled, setMultiOrgUiEnabled] = useState(
() =>
multiOrgExperimentEnabled &&
Boolean(localStorage.getItem("enableMultiOrganizationUi")),
);
useEffect(() => {
if (multiOrgUiEnabled) {
localStorage.setItem("enableMultiOrganizationUi", "true");
} else {
localStorage.removeItem("enableMultiOrganizationUi");
}
}, [multiOrgUiEnabled]);
return (
<Stack spacing={6}>
<Section title="Account" description="Update your account info">
@ -58,23 +41,6 @@ export const AccountPage: FC = () => {
error={groupsQuery.error}
/>
)}
{multiOrgExperimentEnabled && (
<Section
title="Organizations"
description={
<span>Danger: enabling will break things in the UI.</span>
}
>
<Stack>
{multiOrgUiEnabled ? <EnabledBadge /> : <DisabledBadge />}
<Button onClick={() => setMultiOrgUiEnabled((enabled) => !enabled)}>
{multiOrgUiEnabled ? "Disable" : "Enable"} frontend
multi-organization support
</Button>
</Stack>
</Section>
)}
</Stack>
);
};

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import {
MockWorkspaceBuildParameter1,
@ -19,6 +20,7 @@ const meta: Meta<typeof WorkspaceParametersPageView> = {
isSubmitting: false,
workspace: MockWorkspace,
canChangeVersions: true,
onCancel: action("onCancel"),
data: {
buildParameters: [

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
@ -37,6 +38,7 @@ const meta: Meta<typeof WorkspaceScheduleForm> = {
component: WorkspaceScheduleForm,
args: {
template: mockTemplate,
onCancel: action("onCancel"),
},
};

View File

@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { MockWorkspace } from "testHelpers/entities";
import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView";
@ -8,6 +9,7 @@ const meta: Meta<typeof WorkspaceSettingsPageView> = {
args: {
error: undefined,
workspace: MockWorkspace,
onCancel: action("onCancel"),
},
};

View File

@ -13,6 +13,7 @@ import AuditPage from "./pages/AuditPage/AuditPage";
import { DeploySettingsLayout } from "./pages/DeploySettingsPage/DeploySettingsLayout";
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
import LoginPage from "./pages/LoginPage/LoginPage";
import { OrganizationSettingsLayout } from "./pages/OrganizationSettingsPage/OrganizationSettingsLayout";
import { SetupPage } from "./pages/SetupPage/SetupPage";
import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout";
import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout";
@ -220,6 +221,13 @@ const AddNewLicensePage = lazy(
() =>
import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"),
);
const OrganizationSettingsPage = lazy(
() => import("./pages/OrganizationSettingsPage/OrganizationSettingsPage"),
);
const OrganizationSettingsPlaceholder = lazy(
() =>
import("./pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder"),
);
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
);
@ -325,6 +333,33 @@ export const router = createBrowserRouter(
<Route path="/audit" element={<AuditPage />} />
<Route
path="/organizations/:organization?"
element={<OrganizationSettingsLayout />}
>
<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 path="/deployment" element={<DeploySettingsLayout />}>
<Route path="general" element={<GeneralSettingsPage />} />
<Route path="licenses" element={<LicensesSettingsPage />} />

View File

@ -18,7 +18,7 @@ const Language = {
nameTooLong: (name: string, len: number): string => {
return `${name} cannot be longer than ${len} characters`;
},
templateDisplayNameInvalidChars: (name: string): string => {
displayNameInvalidChars: (name: string): string => {
return `${name} must start and end with non-whitespace character`;
},
};
@ -114,9 +114,9 @@ export const onChangeTrimmed =
// REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go
const maxLenName = 32;
const templateDisplayNameMaxLength = 64;
const displayNameMaxLength = 64;
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/;
const templateDisplayNameRE = /^[^\s](.*[^\s])?$/;
const displayNameRE = /^[^\s](.*[^\s])?$/;
// REMARK: see #1756 for name/username semantics
export const nameValidator = (name: string): Yup.StringSchema =>
@ -125,17 +125,12 @@ export const nameValidator = (name: string): Yup.StringSchema =>
.matches(usernameRE, Language.nameInvalidChars(name))
.max(maxLenName, Language.nameTooLong(name, maxLenName));
export const templateDisplayNameValidator = (
displayName: string,
): Yup.StringSchema =>
export const displayNameValidator = (displayName: string): Yup.StringSchema =>
Yup.string()
.matches(
templateDisplayNameRE,
Language.templateDisplayNameInvalidChars(displayName),
)
.matches(displayNameRE, Language.displayNameInvalidChars(displayName))
.max(
templateDisplayNameMaxLength,
Language.nameTooLong(displayName, templateDisplayNameMaxLength),
displayNameMaxLength,
Language.nameTooLong(displayName, displayNameMaxLength),
)
.optional();