Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: split management settings sidebar into deployment and organizat…
…ion settings
  • Loading branch information
jaaydenh committed Dec 17, 2024
commit a1f1b0ec68a3c17a7cac71da0e8cf70687c1a8e6
36 changes: 31 additions & 5 deletions site/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { Stack } from "components/Stack/Stack";
import { type ClassName, useClassName } from "hooks/useClassName";
import type { ElementType, FC, ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { cn } from "utils/cn";

interface SidebarProps {
children?: ReactNode;
}

export const Sidebar: FC<SidebarProps> = ({ children }) => {
return <nav css={styles.sidebar}>{children}</nav>;
return <nav className="w-60 flex-shrink-0">{children}</nav>;
};

interface SidebarHeaderProps {
Expand Down Expand Up @@ -49,6 +50,35 @@ export const SidebarHeader: FC<SidebarHeaderProps> = ({
);
};

interface SettingsSidebarNavItemProps {
children?: ReactNode;
href: string;
end?: boolean;
}

export const SettingsSidebarNavItem: FC<SettingsSidebarNavItemProps> = ({
children,
href,
end,
}) => {
return (
<NavLink
end={end}
to={href}
className={({ isActive }) =>
cn(
"relative text-sm text-content-secondary no-underline font-medium py-2 px-3 hover:bg-surface-secondary rounded-md transition ease-in-out duration-150 ",
{
"font-semibold text-content-primary": isActive,
},
)
}
>
{children}
</NavLink>
);
};

interface SidebarNavItemProps {
children?: ReactNode;
icon: ElementType;
Expand Down Expand Up @@ -78,10 +108,6 @@ export const SidebarNavItem: FC<SidebarNavItemProps> = ({
};

const styles = {
sidebar: {
width: 245,
flexShrink: 0,
},
info: (theme) => ({
...(theme.typography.body2 as CSSObject),
marginBottom: 16,
Expand Down
34 changes: 34 additions & 0 deletions site/src/modules/management/DeploymentSettingsLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { RequirePermission } from "contexts/auth/RequirePermission";
import { type FC, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { DeploymentSidebar } from "./DeploymentSidebar";

const DeploymentSettingsLayout: FC = () => {
const { permissions } = useAuthenticated();

// The deployment settings page also contains users, audit logs, and groups
// so this page must be visible if you can see any of these.
const canViewDeploymentSettingsPage =
permissions.viewDeploymentValues ||
permissions.viewAllUsers ||
permissions.viewAnyAuditLog;

return (
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
<div className="px-10 max-w-screen-2xl">
<div className="flex flex-row gap-12 py-10">
<DeploymentSidebar />
<main css={{ flexGrow: 1 }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
</RequirePermission>
);
};

export default DeploymentSettingsLayout;
5 changes: 2 additions & 3 deletions site/src/modules/management/DeploymentSettingsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ const DeploymentSettingsProvider: FC = () => {
const { permissions } = useAuthenticated();
const deploymentConfigQuery = useQuery(deploymentConfig());

// The deployment settings page also contains users, audit logs, groups and
// organizations, so this page must be visible if you can see any of these.
// The deployment settings page also contains users, audit logs, and groups
// so this page must be visible if you can see any of these.
const canViewDeploymentSettingsPage =
permissions.viewDeploymentValues ||
permissions.viewAllUsers ||
permissions.editAnyOrganization ||
permissions.viewAnyAuditLog;

// Not a huge problem to unload the content in the event of an error,
Expand Down
12 changes: 12 additions & 0 deletions site/src/modules/management/DeploymentSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useAuthenticated } from "contexts/auth/RequireAuth";
import type { FC } from "react";
import { DeploymentSidebarView } from "./DeploymentSidebarView";

/**
* A sidebar for deployment settings.
*/
export const DeploymentSidebar: FC = () => {
const { permissions } = useAuthenticated();

return <DeploymentSidebarView permissions={permissions} />;
};
69 changes: 69 additions & 0 deletions site/src/modules/management/DeploymentSidebarView.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockNoPermissions, MockPermissions } from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { DeploymentSidebarView } from "./DeploymentSidebarView";

const meta: Meta<typeof DeploymentSidebarView> = {
title: "modules/management/SidebarView",
component: DeploymentSidebarView,
decorators: [withDashboardProvider],
parameters: { showOrganizations: true },
args: {
permissions: MockPermissions,
},
};

export default meta;
type Story = StoryObj<typeof DeploymentSidebarView>;

export const NoCreateOrg: Story = {
args: {
permissions: {
...MockPermissions,
createOrganization: false,
},
},
};

export const NoViewUsers: Story = {
args: {
permissions: {
...MockPermissions,
viewAllUsers: false,
},
},
};

export const NoAuditLog: Story = {
args: {
permissions: {
...MockPermissions,
viewAnyAuditLog: false,
},
},
};

export const NoLicenses: Story = {
args: {
permissions: {
...MockPermissions,
viewAllLicenses: false,
},
},
};

export const NoDeploymentValues: Story = {
args: {
permissions: {
...MockPermissions,
viewDeploymentValues: false,
editDeploymentValues: false,
},
},
};

export const NoPermissions: Story = {
args: {
permissions: MockNoPermissions,
},
};
108 changes: 108 additions & 0 deletions site/src/modules/management/DeploymentSidebarView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem as SidebarNavItem,
} from "components/Sidebar/Sidebar";
import type { Permissions } from "contexts/auth/permissions";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import type { FC } from "react";

export interface OrganizationWithPermissions extends Organization {
permissions: AuthorizationResponse;
}

interface DeploymentSidebarProps {
/** Site-wide permissions. */
permissions: Permissions;
}

/**
* A combined deployment settings and organization menu.
*/
export const DeploymentSidebarView: FC<DeploymentSidebarProps> = ({
permissions,
}) => {
const { multiple_organizations: hasPremiumLicense } = useFeatureVisibility();

return (
<BaseSidebar>
<DeploymentSettingsNavigation
permissions={permissions}
isPremium={hasPremiumLicense}
/>
</BaseSidebar>
);
};

interface DeploymentSettingsNavigationProps {
/** Site-wide permissions. */
permissions: Permissions;
isPremium: boolean;
}

/**
* Displays navigation for deployment settings. If active, highlight the main
* menu heading.
*
* Menu items are shown based on the permissions. If organizations can be
* viewed, groups are skipped since they will show under each org instead.
*/
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
permissions,
isPremium,
}) => {
return (
<div>
<div className="flex flex-col gap-1">
{permissions.viewDeploymentValues && (
<SidebarNavItem href="general">General</SidebarNavItem>
)}
{permissions.viewAllLicenses && (
<SidebarNavItem href="licenses">Licenses</SidebarNavItem>
)}
{permissions.editDeploymentValues && (
<SidebarNavItem href="appearance">Appearance</SidebarNavItem>
)}
{permissions.viewDeploymentValues && (
<SidebarNavItem href="userauth">User Authentication</SidebarNavItem>
)}
{permissions.viewDeploymentValues && (
<SidebarNavItem href="external-auth">
External Authentication
</SidebarNavItem>
)}
{/* Not exposing this yet since token exchange is not finished yet.
<SidebarNavItem href="oauth2-provider/ap>
OAuth2 Applications
</SidebarNavItem>*/}
{permissions.viewDeploymentValues && (
<SidebarNavItem href="network">Network</SidebarNavItem>
)}
{permissions.readWorkspaceProxies && (
<SidebarNavItem href="workspace-proxies">
Workspace Proxies
</SidebarNavItem>
)}
{permissions.viewDeploymentValues && (
<SidebarNavItem href="security">Security</SidebarNavItem>
)}
{permissions.viewDeploymentValues && (
<SidebarNavItem href="observability">Observability</SidebarNavItem>
)}
{permissions.viewAllUsers && (
<SidebarNavItem href="users">Users</SidebarNavItem>
)}
{permissions.viewNotificationTemplate && (
<SidebarNavItem href="notifications">
<div className="flex flex-row items-center gap-2">
<span>Notifications</span>
<FeatureStageBadge contentType="beta" size="sm" />
</div>
</SidebarNavItem>
)}
{!isPremium && <SidebarNavItem href="premium">Premium</SidebarNavItem>}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
import type { AuthorizationResponse, 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 { type FC, Suspense, createContext, useContext } from "react";
import { Outlet, useParams } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { OrganizationSidebar } from "./OrganizationSidebar";

export const ManagementSettingsContext = createContext<
ManagementSettingsValue | undefined
export const OrganizationSettingsContext = createContext<
OrganizationSettingsValue | undefined
>(undefined);

type ManagementSettingsValue = Readonly<{
type OrganizationSettingsValue = Readonly<{
organizations: readonly Organization[];
organization?: Organization;
}>;

export const useManagementSettings = (): ManagementSettingsValue => {
const context = useContext(ManagementSettingsContext);
export const useOrganizationSettings = (): OrganizationSettingsValue => {
const context = useContext(OrganizationSettingsContext);
if (!context) {
throw new Error(
"useManagementSettings should be used inside of ManagementSettingsLayout",
"useOrganizationSettings should be used inside of OrganizationSettingsLayout",
);
}

Expand All @@ -43,47 +41,41 @@ export const canEditOrganization = (
);
};

const ManagementSettingsLayout: FC = () => {
const OrganizationSettingsLayout: FC = () => {
const { permissions } = useAuthenticated();
const { organizations } = useDashboard();
const { organization: orgName } = useParams() as {
organization?: string;
};

// The deployment settings page also contains users, audit logs, groups and
// organizations, so this page must be visible if you can see any of these.
const canViewDeploymentSettingsPage =
permissions.viewDeploymentValues ||
permissions.viewAllUsers ||
permissions.editAnyOrganization ||
permissions.viewAnyAuditLog;
const canViewOrganizationSettingsPage = permissions.editAnyOrganization;

const organization =
organizations && orgName
? organizations.find((org) => org.name === orgName)
: undefined;

return (
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
<ManagementSettingsContext.Provider
<RequirePermission isFeatureVisible={canViewOrganizationSettingsPage}>
<OrganizationSettingsContext.Provider
value={{
organizations,
organization,
}}
>
<Margins>
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
<Sidebar />
<div className="px-10 max-w-screen-2xl">
<div className="flex flex-row gap-12 py-10">
<OrganizationSidebar />
<main css={{ flexGrow: 1 }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</Stack>
</Margins>
</ManagementSettingsContext.Provider>
</div>
</div>
</OrganizationSettingsContext.Provider>
</RequirePermission>
);
};

export default ManagementSettingsLayout;
export default OrganizationSettingsLayout;
Loading