mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat(site): add basic organization management ui (#13288)
This commit is contained in:
committed by
GitHub
parent
07cd9acb2c
commit
8c1bd32c33
@ -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"`
|
||||
}
|
||||
|
@ -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> => {
|
||||
|
46
site/src/api/queries/organizations.ts
Normal file
46
site/src/api/queries/organizations.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
2
site/src/api/typesGenerated.ts
generated
2
site/src/api/typesGenerated.ts
generated
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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");
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
|
@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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.",
|
||||
|
@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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 };
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>>;
|
@ -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;
|
182
site/src/pages/OrganizationSettingsPage/Sidebar.tsx
Normal file
182
site/src/pages/OrganizationSettingsPage/Sidebar.tsx
Normal 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>;
|
@ -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 (
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
|
@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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: [
|
||||
|
@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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 />} />
|
||||
|
@ -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();
|
||||
|
||||
|
Reference in New Issue
Block a user