Thanks to visit codestin.com
Credit goes to github.com

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 <[email protected]>
1 parent fd60e1c commit 1d925ab

26 files changed

+243
-189
lines changed

.vscode/settings.json

+1
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

+1
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

+3-5
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

+24-4
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>;
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

+4-23
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

+2-1
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

+18-21
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

+3-8
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)