Skip to content

Commit 19fb6e5

Browse files
committed
Merge branch 'lilac/deployment-settings-provider' into mes/deploy-bug
2 parents 9fcf3e7 + 4496a75 commit 19fb6e5

File tree

20 files changed

+249
-278
lines changed

20 files changed

+249
-278
lines changed

site/jest.setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +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: (b: NodeJS.ArrayBufferView) => crypto.randomFillSync(b),
51+
getRandomValues: crypto.randomFillSync,
5252
},
5353
});
5454

site/src/contexts/auth/RequirePermission.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,18 @@ import { Navigate } from "react-router-dom";
33

44
export interface RequirePermissionProps {
55
children?: ReactNode;
6-
permitted: boolean;
7-
unpermittedRedirect?: `/${string}`;
6+
isFeatureVisible: boolean;
87
}
98

109
/**
1110
* Wraps routes that are available based on RBAC or licensing.
1211
*/
1312
export const RequirePermission: FC<RequirePermissionProps> = ({
1413
children,
15-
permitted,
16-
unpermittedRedirect = "/workspaces",
14+
isFeatureVisible,
1715
}) => {
18-
if (!permitted) {
19-
return <Navigate to={unpermittedRedirect} replace />;
16+
if (!isFeatureVisible) {
17+
return <Navigate to="/workspaces" />;
2018
}
2119

2220
return <>{children}</>;

site/src/contexts/auth/permissions.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export const checks = {
2323
viewNotificationTemplate: "viewNotificationTemplate",
2424
} as const satisfies Record<string, string>;
2525

26-
type PermissionType = keyof typeof checks;
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];
2730

2831
export const permissionsToCheck = {
2932
[checks.viewAllUsers]: {
@@ -149,6 +152,6 @@ export const permissionsToCheck = {
149152
},
150153
action: "read",
151154
},
152-
} as const satisfies Record<PermissionType, AuthorizationCheck>;
155+
} as const satisfies Record<PermissionValue, AuthorizationCheck>;
153156

154-
export type Permissions = Record<PermissionType, 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;
Lines changed: 38 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
1-
import type { DeploymentConfig } from "api/api";
2-
import { deploymentConfig } from "api/queries/deployment";
3-
import type { AuthorizationResponse } from "api/typesGenerated";
4-
import { ErrorAlert } from "components/Alert/ErrorAlert";
1+
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
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";
10-
import type { Permissions } from "contexts/auth/permissions";
117
import { useDashboard } from "modules/dashboard/useDashboard";
128
import { type FC, Suspense, createContext, useContext } from "react";
13-
import { useQuery } from "react-query";
14-
import { Outlet, useLocation } from "react-router-dom";
9+
import { Outlet, useParams } from "react-router-dom";
1510
import { Sidebar } from "./Sidebar";
1611

17-
type ManagementSettingsValue = Readonly<{
18-
deploymentValues: DeploymentConfig | undefined;
19-
}>;
20-
2112
export const ManagementSettingsContext = createContext<
2213
ManagementSettingsValue | undefined
2314
>(undefined);
2415

16+
type ManagementSettingsValue = Readonly<{
17+
organizations: readonly Organization[];
18+
organization?: Organization;
19+
}>;
20+
2521
export const useManagementSettings = (): ManagementSettingsValue => {
2622
const context = useContext(ManagementSettingsContext);
2723
if (!context) {
@@ -47,134 +43,47 @@ export const canEditOrganization = (
4743
);
4844
};
4945

50-
export const isManagementRoutePermitted = (
51-
locationPath: string,
52-
permissions: Permissions,
53-
showOrganizations: boolean,
54-
): boolean => {
55-
if (!locationPath.startsWith("/")) {
56-
return false;
57-
}
58-
59-
if (locationPath.startsWith("/organizations")) {
60-
return showOrganizations;
61-
}
62-
63-
if (!locationPath.startsWith("/deployment")) {
64-
return false;
65-
}
66-
67-
// Logic for deployment routes should mirror the conditions used to display
68-
// the sidebar tabs from SidebarView.tsx
69-
const href = locationPath.replace(/^\/deployment/, "");
70-
71-
if (href === "/" || href === "") {
72-
const hasAtLeastOnePermission = Object.values(permissions).some((v) => v);
73-
return hasAtLeastOnePermission;
74-
}
75-
if (href.startsWith("/general")) {
76-
return permissions.viewDeploymentValues;
77-
}
78-
if (href.startsWith("/licenses")) {
79-
return permissions.viewAllLicenses;
80-
}
81-
if (href.startsWith("/appearance")) {
82-
return permissions.editDeploymentValues;
83-
}
84-
if (href.startsWith("/userauth")) {
85-
return permissions.viewDeploymentValues;
86-
}
87-
if (href.startsWith("/external-auth")) {
88-
return permissions.viewDeploymentValues;
89-
}
90-
if (href.startsWith("/network")) {
91-
return permissions.viewDeploymentValues;
92-
}
93-
if (href.startsWith("/workspace-proxies")) {
94-
return permissions.readWorkspaceProxies;
95-
}
96-
if (href.startsWith("/security")) {
97-
return permissions.viewDeploymentValues;
98-
}
99-
if (href.startsWith("/observability")) {
100-
return permissions.viewDeploymentValues;
101-
}
102-
if (href.startsWith("/users")) {
103-
return permissions.viewAllUsers;
104-
}
105-
if (href.startsWith("/notifications")) {
106-
return permissions.viewNotificationTemplate;
107-
}
108-
if (href.startsWith("/oauth2-provider")) {
109-
return permissions.viewExternalAuthConfig;
110-
}
111-
112-
return false;
113-
};
114-
115-
/**
116-
* A multi-org capable settings page layout.
117-
*
118-
* If multi-org is not enabled or licensed, this is the wrong layout to use.
119-
* See DeploySettingsLayoutInner instead.
120-
*/
121-
export const ManagementSettingsLayout: FC = () => {
122-
const location = useLocation();
123-
const { showOrganizations } = useDashboard();
46+
const ManagementSettingsLayout: FC = () => {
12447
const { permissions } = useAuthenticated();
125-
const deploymentConfigQuery = useQuery({
126-
...deploymentConfig(),
127-
enabled: permissions.viewDeploymentValues,
128-
});
129-
130-
// Have to make check more specific, because if the query is disabled, it
131-
// will be forever stuck in the loading state. The loading state is only
132-
// relevant to owners right now
133-
if (permissions.viewDeploymentValues && deploymentConfigQuery.isLoading) {
134-
return <Loader />;
135-
}
136-
137-
const canViewAtLeastOneTab =
48+
const { organizations } = useDashboard();
49+
const { organization: orgName } = useParams() as {
50+
organization?: string;
51+
};
52+
53+
// The deployment settings page also contains users, audit logs, groups and
54+
// organizations, so this page must be visible if you can see any of these.
55+
const canViewDeploymentSettingsPage =
13856
permissions.viewDeploymentValues ||
13957
permissions.viewAllUsers ||
14058
permissions.editAnyOrganization ||
14159
permissions.viewAnyAuditLog;
14260

143-
return (
144-
<RequirePermission
145-
permitted={
146-
canViewAtLeastOneTab &&
147-
isManagementRoutePermitted(
148-
location.pathname,
149-
permissions,
150-
showOrganizations,
151-
)
152-
}
153-
unpermittedRedirect={
154-
canViewAtLeastOneTab && !location.pathname.startsWith("/deployment")
155-
? "/deployment"
156-
: "/workspaces"
157-
}
158-
>
159-
<Margins>
160-
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
161-
<Sidebar />
162-
163-
<main css={{ flexGrow: 1 }}>
164-
{deploymentConfigQuery.isError && (
165-
<ErrorAlert error={deploymentConfigQuery.error} />
166-
)}
61+
const organization =
62+
organizations && orgName
63+
? organizations.find((org) => org.name === orgName)
64+
: undefined;
16765

168-
<ManagementSettingsContext.Provider
169-
value={{ deploymentValues: deploymentConfigQuery.data }}
170-
>
66+
return (
67+
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
68+
<ManagementSettingsContext.Provider
69+
value={{
70+
organizations,
71+
organization,
72+
}}
73+
>
74+
<Margins>
75+
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
76+
<Sidebar />
77+
<main css={{ flexGrow: 1 }}>
17178
<Suspense fallback={<Loader />}>
17279
<Outlet />
17380
</Suspense>
174-
</ManagementSettingsContext.Provider>
175-
</main>
176-
</Stack>
177-
</Margins>
81+
</main>
82+
</Stack>
83+
</Margins>
84+
</ManagementSettingsContext.Provider>
17885
</RequirePermission>
17986
);
18087
};
88+
89+
export default ManagementSettingsLayout;

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ export const NoDeploymentValues: Story = {
9696
};
9797

9898
export const NoPermissions: Story = {
99-
args: { permissions: MockNoPermissions },
99+
args: {
100+
permissions: MockNoPermissions,
101+
},
100102
};
101103

102104
export const NoSelected: Story = {

site/src/modules/management/SidebarView.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,6 @@ interface OrganizationSettingsNavigationProps {
240240
const OrganizationSettingsNavigation: FC<
241241
OrganizationSettingsNavigationProps
242242
> = ({ active, organization }) => {
243-
const { experiments } = useDashboard();
244-
245243
return (
246244
<>
247245
<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)