Skip to content

Commit 1d925ab

Browse files
Parkreineraslilac
andauthored
fix: ensure user admins can always see users table (#15226)
Closes #15212 ## Changes made - Updated logic so that proxy config is only requested when appropriate, instead of for all users on all deployment pages - Split up the main context provider for the `/deployment` and `/organizations` routes, and updated layout logic for `ManagementSettingsLayout` layout component. This ensures the sidebar is always visible, even if request errors happen - Added additional routing safeguards to make sure that even if a user can view one page in the deployment section, they won't be able to navigate directly to any arbitrary deployment page - Updated logic for sidebar navigation to ensure that nav items only appear when the user truly has permission - Centralized a lot of the orgs logic into the `useAuthenticated` hook - Added additional check cases to the `permissions.tsx` file, to give more granularity, and added missing type-checking - Extended the API for the `RequirePermissions` component to let it redirect users anywhere - Updated some of our testing setup files to ensure that types were defined correctly --------- Co-authored-by: McKayla Washburn <mckayla@hey.com>
1 parent fd60e1c commit 1d925ab

26 files changed

+243
-189
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
"unauthenticate",
176176
"unconvert",
177177
"untar",
178+
"userauth",
178179
"userspace",
179180
"VMID",
180181
"walkthrough",

site/e2e/global.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ test("setup deployment", async ({ page }) => {
3535
expect(constants.license.split(".").length).toBe(3); // otherwise it's invalid
3636

3737
await page.goto("/deployment/licenses", { waitUntil: "domcontentloaded" });
38+
await expect(page).toHaveTitle("License Settings - Coder");
3839

3940
await page.getByText("Add a license").click();
4041
await page.getByRole("textbox").fill(constants.license);

site/jest.setup.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "@testing-library/jest-dom";
22
import "jest-location-mock";
33
import { cleanup } from "@testing-library/react";
4-
import crypto from "crypto";
4+
import crypto from "node:crypto";
55
import { useMemo } from "react";
66
import type { Region } from "api/typesGenerated";
77
import type { ProxyLatencyReport } from "contexts/useProxyLatency";
@@ -48,9 +48,7 @@ global.ResizeObserver = require("resize-observer-polyfill");
4848
// Polyfill the getRandomValues that is used on utils/random.ts
4949
Object.defineProperty(global.self, "crypto", {
5050
value: {
51-
getRandomValues: function (buffer: Buffer) {
52-
return crypto.randomFillSync(buffer);
53-
},
51+
getRandomValues: crypto.randomFillSync,
5452
},
5553
});
5654

@@ -72,5 +70,5 @@ afterEach(() => {
7270
// Clean up after the tests are finished.
7371
afterAll(() => server.close());
7472

75-
// This is needed because we are compiling under `--isolatedModules`
73+
// biome-ignore lint/complexity/noUselessEmptyExport: This is needed because we are compiling under `--isolatedModules`
7674
export {};

site/src/contexts/auth/permissions.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AuthorizationCheck } from "api/typesGenerated";
2+
13
export const checks = {
24
viewAllUsers: "viewAllUsers",
35
updateUsers: "updateUsers",
@@ -11,13 +13,20 @@ export const checks = {
1113
viewUpdateCheck: "viewUpdateCheck",
1214
viewExternalAuthConfig: "viewExternalAuthConfig",
1315
viewDeploymentStats: "viewDeploymentStats",
16+
readWorkspaceProxies: "readWorkspaceProxies",
1417
editWorkspaceProxies: "editWorkspaceProxies",
1518
createOrganization: "createOrganization",
1619
editAnyOrganization: "editAnyOrganization",
1720
viewAnyGroup: "viewAnyGroup",
1821
createGroup: "createGroup",
1922
viewAllLicenses: "viewAllLicenses",
20-
} as const;
23+
viewNotificationTemplate: "viewNotificationTemplate",
24+
} as const satisfies Record<string, string>;
25+
26+
// Type expression seems a little redundant (`keyof typeof checks` has the same
27+
// result), just because each key-value pair is currently symmetrical; this may
28+
// change down the line
29+
type PermissionValue = (typeof checks)[keyof typeof checks];
2130

2231
export const permissionsToCheck = {
2332
[checks.viewAllUsers]: {
@@ -94,6 +103,12 @@ export const permissionsToCheck = {
94103
},
95104
action: "read",
96105
},
106+
[checks.readWorkspaceProxies]: {
107+
object: {
108+
resource_type: "workspace_proxy",
109+
},
110+
action: "read",
111+
},
97112
[checks.editWorkspaceProxies]: {
98113
object: {
99114
resource_type: "workspace_proxy",
@@ -116,7 +131,6 @@ export const permissionsToCheck = {
116131
[checks.viewAnyGroup]: {
117132
object: {
118133
resource_type: "group",
119-
org_id: "any",
120134
},
121135
action: "read",
122136
},
@@ -132,6 +146,12 @@ export const permissionsToCheck = {
132146
},
133147
action: "read",
134148
},
135-
} as const;
149+
[checks.viewNotificationTemplate]: {
150+
object: {
151+
resource_type: "notification_template",
152+
},
153+
action: "read",
154+
},
155+
} as const satisfies Record<PermissionValue, AuthorizationCheck>;
136156

137-
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>;
157+
export type Permissions = Record<PermissionValue, boolean>;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { DeploymentConfig } from "api/api";
2+
import { deploymentConfig } from "api/queries/deployment";
3+
import { ErrorAlert } from "components/Alert/ErrorAlert";
4+
import { Loader } from "components/Loader/Loader";
5+
import { useAuthenticated } from "contexts/auth/RequireAuth";
6+
import { RequirePermission } from "contexts/auth/RequirePermission";
7+
import { type FC, createContext, useContext } from "react";
8+
import { useQuery } from "react-query";
9+
import { Outlet } from "react-router-dom";
10+
11+
export const DeploymentSettingsContext = createContext<
12+
DeploymentSettingsValue | undefined
13+
>(undefined);
14+
15+
type DeploymentSettingsValue = Readonly<{
16+
deploymentConfig: DeploymentConfig;
17+
}>;
18+
19+
export const useDeploymentSettings = (): DeploymentSettingsValue => {
20+
const context = useContext(DeploymentSettingsContext);
21+
if (!context) {
22+
throw new Error(
23+
`${useDeploymentSettings.name} should be used inside of ${DeploymentSettingsProvider.name}`,
24+
);
25+
}
26+
27+
return context;
28+
};
29+
30+
const DeploymentSettingsProvider: FC = () => {
31+
const { permissions } = useAuthenticated();
32+
const deploymentConfigQuery = useQuery(deploymentConfig());
33+
34+
// The deployment settings page also contains users, audit logs, groups and
35+
// organizations, so this page must be visible if you can see any of these.
36+
const canViewDeploymentSettingsPage =
37+
permissions.viewDeploymentValues ||
38+
permissions.viewAllUsers ||
39+
permissions.editAnyOrganization ||
40+
permissions.viewAnyAuditLog;
41+
42+
// Not a huge problem to unload the content in the event of an error,
43+
// because the sidebar rendering isn't tied to this. Even if the user hits
44+
// a 403 error, they'll still have navigation options
45+
if (deploymentConfigQuery.error) {
46+
return <ErrorAlert error={deploymentConfigQuery.error} />;
47+
}
48+
49+
if (!deploymentConfigQuery.data) {
50+
return <Loader />;
51+
}
52+
53+
return (
54+
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
55+
<DeploymentSettingsContext.Provider
56+
value={{ deploymentConfig: deploymentConfigQuery.data }}
57+
>
58+
<Outlet />
59+
</DeploymentSettingsContext.Provider>
60+
</RequirePermission>
61+
);
62+
};
63+
64+
export default DeploymentSettingsProvider;

site/src/modules/management/ManagementSettingsLayout.tsx

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import type { DeploymentConfig } from "api/api";
2-
import { deploymentConfig } from "api/queries/deployment";
31
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
4-
import { ErrorAlert } from "components/Alert/ErrorAlert";
52
import { Loader } from "components/Loader/Loader";
63
import { Margins } from "components/Margins/Margins";
74
import { Stack } from "components/Stack/Stack";
85
import { useAuthenticated } from "contexts/auth/RequireAuth";
96
import { RequirePermission } from "contexts/auth/RequirePermission";
107
import { useDashboard } from "modules/dashboard/useDashboard";
118
import { type FC, Suspense, createContext, useContext } from "react";
12-
import { useQuery } from "react-query";
139
import { Outlet, useParams } from "react-router-dom";
1410
import { Sidebar } from "./Sidebar";
1511

@@ -18,7 +14,6 @@ export const ManagementSettingsContext = createContext<
1814
>(undefined);
1915

2016
type ManagementSettingsValue = Readonly<{
21-
deploymentValues: DeploymentConfig;
2217
organizations: readonly Organization[];
2318
organization?: Organization;
2419
}>;
@@ -48,15 +43,8 @@ export const canEditOrganization = (
4843
);
4944
};
5045

51-
/**
52-
* A multi-org capable settings page layout.
53-
*
54-
* If multi-org is not enabled or licensed, this is the wrong layout to use.
55-
* See DeploySettingsLayoutInner instead.
56-
*/
57-
export const ManagementSettingsLayout: FC = () => {
46+
const ManagementSettingsLayout: FC = () => {
5847
const { permissions } = useAuthenticated();
59-
const deploymentConfigQuery = useQuery(deploymentConfig());
6048
const { organizations } = useDashboard();
6149
const { organization: orgName } = useParams() as {
6250
organization?: string;
@@ -70,14 +58,6 @@ export const ManagementSettingsLayout: FC = () => {
7058
permissions.editAnyOrganization ||
7159
permissions.viewAnyAuditLog;
7260

73-
if (deploymentConfigQuery.error) {
74-
return <ErrorAlert error={deploymentConfigQuery.error} />;
75-
}
76-
77-
if (!deploymentConfigQuery.data) {
78-
return <Loader />;
79-
}
80-
8161
const organization =
8262
organizations && orgName
8363
? organizations.find((org) => org.name === orgName)
@@ -87,15 +67,14 @@ export const ManagementSettingsLayout: FC = () => {
8767
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
8868
<ManagementSettingsContext.Provider
8969
value={{
90-
deploymentValues: deploymentConfigQuery.data,
9170
organizations,
9271
organization,
9372
}}
9473
>
9574
<Margins>
9675
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
9776
<Sidebar />
98-
<main css={{ width: "100%" }}>
77+
<main css={{ flexGrow: 1 }}>
9978
<Suspense fallback={<Loader />}>
10079
<Outlet />
10180
</Suspense>
@@ -106,3 +85,5 @@ export const ManagementSettingsLayout: FC = () => {
10685
</RequirePermission>
10786
);
10887
};
88+
89+
export default ManagementSettingsLayout;

site/src/modules/management/SidebarView.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Meta, StoryObj } from "@storybook/react";
22
import {
3+
MockNoPermissions,
34
MockOrganization,
45
MockOrganization2,
56
MockPermissions,
@@ -96,7 +97,7 @@ export const NoDeploymentValues: Story = {
9697

9798
export const NoPermissions: Story = {
9899
args: {
99-
permissions: {},
100+
permissions: MockNoPermissions,
100101
},
101102
};
102103

site/src/modules/management/SidebarView.tsx

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,15 @@ import { cx } from "@emotion/css";
22
import type { Interpolation, Theme } from "@emotion/react";
33
import AddIcon from "@mui/icons-material/Add";
44
import SettingsIcon from "@mui/icons-material/Settings";
5-
import type {
6-
AuthorizationResponse,
7-
Experiments,
8-
Organization,
9-
} from "api/typesGenerated";
5+
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
106
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
117
import { Loader } from "components/Loader/Loader";
128
import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar";
139
import { Stack } from "components/Stack/Stack";
1410
import { UserAvatar } from "components/UserAvatar/UserAvatar";
11+
import type { Permissions } from "contexts/auth/permissions";
1512
import { type ClassName, useClassName } from "hooks/useClassName";
1613
import { useDashboard } from "modules/dashboard/useDashboard";
17-
import { linkToUsers } from "modules/navigation";
1814
import type { FC, ReactNode } from "react";
1915
import { Link, NavLink } from "react-router-dom";
2016

@@ -30,7 +26,7 @@ interface SidebarProps {
3026
/** Organizations and their permissions or undefined if still fetching. */
3127
organizations: OrganizationWithPermissions[] | undefined;
3228
/** Site-wide permissions. */
33-
permissions: AuthorizationResponse;
29+
permissions: Permissions;
3430
}
3531

3632
/**
@@ -72,7 +68,7 @@ interface DeploymentSettingsNavigationProps {
7268
/** Whether a deployment setting page is being viewed. */
7369
active: boolean;
7470
/** Site-wide permissions. */
75-
permissions: AuthorizationResponse;
71+
permissions: Permissions;
7672
}
7773

7874
/**
@@ -130,10 +126,11 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
130126
{permissions.viewDeploymentValues && (
131127
<SidebarNavSubItem href="network">Network</SidebarNavSubItem>
132128
)}
133-
{/* All users can view workspace regions. */}
134-
<SidebarNavSubItem href="workspace-proxies">
135-
Workspace Proxies
136-
</SidebarNavSubItem>
129+
{permissions.readWorkspaceProxies && (
130+
<SidebarNavSubItem href="workspace-proxies">
131+
Workspace Proxies
132+
</SidebarNavSubItem>
133+
)}
137134
{permissions.viewDeploymentValues && (
138135
<SidebarNavSubItem href="security">Security</SidebarNavSubItem>
139136
)}
@@ -145,12 +142,14 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
145142
{permissions.viewAllUsers && (
146143
<SidebarNavSubItem href="users">Users</SidebarNavSubItem>
147144
)}
148-
<SidebarNavSubItem href="notifications">
149-
<Stack direction="row" alignItems="center" spacing={1}>
150-
<span>Notifications</span>
151-
<FeatureStageBadge contentType="beta" size="sm" />
152-
</Stack>
153-
</SidebarNavSubItem>
145+
{permissions.viewNotificationTemplate && (
146+
<SidebarNavSubItem href="notifications">
147+
<Stack direction="row" alignItems="center" spacing={1}>
148+
<span>Notifications</span>
149+
<FeatureStageBadge contentType="beta" size="sm" />
150+
</Stack>
151+
</SidebarNavSubItem>
152+
)}
154153
</Stack>
155154
)}
156155
</div>
@@ -167,7 +166,7 @@ interface OrganizationsSettingsNavigationProps {
167166
/** Organizations and their permissions or undefined if still fetching. */
168167
organizations: OrganizationWithPermissions[] | undefined;
169168
/** Site-wide permissions. */
170-
permissions: AuthorizationResponse;
169+
permissions: Permissions;
171170
}
172171

173172
/**
@@ -241,8 +240,6 @@ interface OrganizationSettingsNavigationProps {
241240
const OrganizationSettingsNavigation: FC<
242241
OrganizationSettingsNavigationProps
243242
> = ({ active, organization }) => {
244-
const { experiments } = useDashboard();
245-
246243
return (
247244
<>
248245
<SidebarNavItem

site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
11
import { Loader } from "components/Loader/Loader";
2-
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
2+
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
33
import type { FC } from "react";
44
import { Helmet } from "react-helmet-async";
55
import { pageTitle } from "utils/page";
66
import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
77

88
const ExternalAuthSettingsPage: FC = () => {
9-
const { deploymentValues } = useManagementSettings();
9+
const { deploymentConfig } = useDeploymentSettings();
1010

1111
return (
1212
<>
1313
<Helmet>
1414
<title>{pageTitle("External Authentication Settings")}</title>
1515
</Helmet>
16-
17-
{deploymentValues ? (
18-
<ExternalAuthSettingsPageView config={deploymentValues.config} />
19-
) : (
20-
<Loader />
21-
)}
16+
<ExternalAuthSettingsPageView config={deploymentConfig.config} />
2217
</>
2318
);
2419
};

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy