mirror of
https://github.com/coder/coder.git
synced 2025-03-14 10:09:57 +00:00
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>
This commit is contained in:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -175,6 +175,7 @@
|
||||
"unauthenticate",
|
||||
"unconvert",
|
||||
"untar",
|
||||
"userauth",
|
||||
"userspace",
|
||||
"VMID",
|
||||
"walkthrough",
|
||||
|
@ -35,6 +35,7 @@ test("setup deployment", async ({ page }) => {
|
||||
expect(constants.license.split(".").length).toBe(3); // otherwise it's invalid
|
||||
|
||||
await page.goto("/deployment/licenses", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveTitle("License Settings - Coder");
|
||||
|
||||
await page.getByText("Add a license").click();
|
||||
await page.getByRole("textbox").fill(constants.license);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import "jest-location-mock";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import crypto from "crypto";
|
||||
import crypto from "node:crypto";
|
||||
import { useMemo } from "react";
|
||||
import type { Region } from "api/typesGenerated";
|
||||
import type { ProxyLatencyReport } from "contexts/useProxyLatency";
|
||||
@ -48,9 +48,7 @@ global.ResizeObserver = require("resize-observer-polyfill");
|
||||
// Polyfill the getRandomValues that is used on utils/random.ts
|
||||
Object.defineProperty(global.self, "crypto", {
|
||||
value: {
|
||||
getRandomValues: function (buffer: Buffer) {
|
||||
return crypto.randomFillSync(buffer);
|
||||
},
|
||||
getRandomValues: crypto.randomFillSync,
|
||||
},
|
||||
});
|
||||
|
||||
@ -72,5 +70,5 @@ afterEach(() => {
|
||||
// Clean up after the tests are finished.
|
||||
afterAll(() => server.close());
|
||||
|
||||
// This is needed because we are compiling under `--isolatedModules`
|
||||
// biome-ignore lint/complexity/noUselessEmptyExport: This is needed because we are compiling under `--isolatedModules`
|
||||
export {};
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { AuthorizationCheck } from "api/typesGenerated";
|
||||
|
||||
export const checks = {
|
||||
viewAllUsers: "viewAllUsers",
|
||||
updateUsers: "updateUsers",
|
||||
@ -11,13 +13,20 @@ export const checks = {
|
||||
viewUpdateCheck: "viewUpdateCheck",
|
||||
viewExternalAuthConfig: "viewExternalAuthConfig",
|
||||
viewDeploymentStats: "viewDeploymentStats",
|
||||
readWorkspaceProxies: "readWorkspaceProxies",
|
||||
editWorkspaceProxies: "editWorkspaceProxies",
|
||||
createOrganization: "createOrganization",
|
||||
editAnyOrganization: "editAnyOrganization",
|
||||
viewAnyGroup: "viewAnyGroup",
|
||||
createGroup: "createGroup",
|
||||
viewAllLicenses: "viewAllLicenses",
|
||||
} as const;
|
||||
viewNotificationTemplate: "viewNotificationTemplate",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
// Type expression seems a little redundant (`keyof typeof checks` has the same
|
||||
// result), just because each key-value pair is currently symmetrical; this may
|
||||
// change down the line
|
||||
type PermissionValue = (typeof checks)[keyof typeof checks];
|
||||
|
||||
export const permissionsToCheck = {
|
||||
[checks.viewAllUsers]: {
|
||||
@ -94,6 +103,12 @@ export const permissionsToCheck = {
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
[checks.readWorkspaceProxies]: {
|
||||
object: {
|
||||
resource_type: "workspace_proxy",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
[checks.editWorkspaceProxies]: {
|
||||
object: {
|
||||
resource_type: "workspace_proxy",
|
||||
@ -116,7 +131,6 @@ export const permissionsToCheck = {
|
||||
[checks.viewAnyGroup]: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
org_id: "any",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
@ -132,6 +146,12 @@ export const permissionsToCheck = {
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
} as const;
|
||||
[checks.viewNotificationTemplate]: {
|
||||
object: {
|
||||
resource_type: "notification_template",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
} as const satisfies Record<PermissionValue, AuthorizationCheck>;
|
||||
|
||||
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>;
|
||||
export type Permissions = Record<PermissionValue, boolean>;
|
||||
|
64
site/src/modules/management/DeploymentSettingsProvider.tsx
Normal file
64
site/src/modules/management/DeploymentSettingsProvider.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import type { DeploymentConfig } from "api/api";
|
||||
import { deploymentConfig } from "api/queries/deployment";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { RequirePermission } from "contexts/auth/RequirePermission";
|
||||
import { type FC, createContext, useContext } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export const DeploymentSettingsContext = createContext<
|
||||
DeploymentSettingsValue | undefined
|
||||
>(undefined);
|
||||
|
||||
type DeploymentSettingsValue = Readonly<{
|
||||
deploymentConfig: DeploymentConfig;
|
||||
}>;
|
||||
|
||||
export const useDeploymentSettings = (): DeploymentSettingsValue => {
|
||||
const context = useContext(DeploymentSettingsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`${useDeploymentSettings.name} should be used inside of ${DeploymentSettingsProvider.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
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.
|
||||
const canViewDeploymentSettingsPage =
|
||||
permissions.viewDeploymentValues ||
|
||||
permissions.viewAllUsers ||
|
||||
permissions.editAnyOrganization ||
|
||||
permissions.viewAnyAuditLog;
|
||||
|
||||
// Not a huge problem to unload the content in the event of an error,
|
||||
// because the sidebar rendering isn't tied to this. Even if the user hits
|
||||
// a 403 error, they'll still have navigation options
|
||||
if (deploymentConfigQuery.error) {
|
||||
return <ErrorAlert error={deploymentConfigQuery.error} />;
|
||||
}
|
||||
|
||||
if (!deploymentConfigQuery.data) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
|
||||
<DeploymentSettingsContext.Provider
|
||||
value={{ deploymentConfig: deploymentConfigQuery.data }}
|
||||
>
|
||||
<Outlet />
|
||||
</DeploymentSettingsContext.Provider>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentSettingsProvider;
|
@ -1,7 +1,4 @@
|
||||
import type { DeploymentConfig } from "api/api";
|
||||
import { deploymentConfig } from "api/queries/deployment";
|
||||
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
@ -9,7 +6,6 @@ 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 { useQuery } from "react-query";
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
@ -18,7 +14,6 @@ export const ManagementSettingsContext = createContext<
|
||||
>(undefined);
|
||||
|
||||
type ManagementSettingsValue = Readonly<{
|
||||
deploymentValues: DeploymentConfig;
|
||||
organizations: readonly Organization[];
|
||||
organization?: Organization;
|
||||
}>;
|
||||
@ -48,15 +43,8 @@ export const canEditOrganization = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A multi-org capable settings page layout.
|
||||
*
|
||||
* If multi-org is not enabled or licensed, this is the wrong layout to use.
|
||||
* See DeploySettingsLayoutInner instead.
|
||||
*/
|
||||
export const ManagementSettingsLayout: FC = () => {
|
||||
const ManagementSettingsLayout: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const deploymentConfigQuery = useQuery(deploymentConfig());
|
||||
const { organizations } = useDashboard();
|
||||
const { organization: orgName } = useParams() as {
|
||||
organization?: string;
|
||||
@ -70,14 +58,6 @@ export const ManagementSettingsLayout: FC = () => {
|
||||
permissions.editAnyOrganization ||
|
||||
permissions.viewAnyAuditLog;
|
||||
|
||||
if (deploymentConfigQuery.error) {
|
||||
return <ErrorAlert error={deploymentConfigQuery.error} />;
|
||||
}
|
||||
|
||||
if (!deploymentConfigQuery.data) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const organization =
|
||||
organizations && orgName
|
||||
? organizations.find((org) => org.name === orgName)
|
||||
@ -87,7 +67,6 @@ export const ManagementSettingsLayout: FC = () => {
|
||||
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
|
||||
<ManagementSettingsContext.Provider
|
||||
value={{
|
||||
deploymentValues: deploymentConfigQuery.data,
|
||||
organizations,
|
||||
organization,
|
||||
}}
|
||||
@ -95,7 +74,7 @@ export const ManagementSettingsLayout: FC = () => {
|
||||
<Margins>
|
||||
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
|
||||
<Sidebar />
|
||||
<main css={{ width: "100%" }}>
|
||||
<main css={{ flexGrow: 1 }}>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
@ -106,3 +85,5 @@ export const ManagementSettingsLayout: FC = () => {
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagementSettingsLayout;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockNoPermissions,
|
||||
MockOrganization,
|
||||
MockOrganization2,
|
||||
MockPermissions,
|
||||
@ -96,7 +97,7 @@ export const NoDeploymentValues: Story = {
|
||||
|
||||
export const NoPermissions: Story = {
|
||||
args: {
|
||||
permissions: {},
|
||||
permissions: MockNoPermissions,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -2,19 +2,15 @@ import { cx } from "@emotion/css";
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import type {
|
||||
AuthorizationResponse,
|
||||
Experiments,
|
||||
Organization,
|
||||
} from "api/typesGenerated";
|
||||
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import type { Permissions } from "contexts/auth/permissions";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { linkToUsers } from "modules/navigation";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
|
||||
@ -30,7 +26,7 @@ interface SidebarProps {
|
||||
/** Organizations and their permissions or undefined if still fetching. */
|
||||
organizations: OrganizationWithPermissions[] | undefined;
|
||||
/** Site-wide permissions. */
|
||||
permissions: AuthorizationResponse;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -72,7 +68,7 @@ interface DeploymentSettingsNavigationProps {
|
||||
/** Whether a deployment setting page is being viewed. */
|
||||
active: boolean;
|
||||
/** Site-wide permissions. */
|
||||
permissions: AuthorizationResponse;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -130,10 +126,11 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="network">Network</SidebarNavSubItem>
|
||||
)}
|
||||
{/* All users can view workspace regions. */}
|
||||
<SidebarNavSubItem href="workspace-proxies">
|
||||
Workspace Proxies
|
||||
</SidebarNavSubItem>
|
||||
{permissions.readWorkspaceProxies && (
|
||||
<SidebarNavSubItem href="workspace-proxies">
|
||||
Workspace Proxies
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="security">Security</SidebarNavSubItem>
|
||||
)}
|
||||
@ -145,12 +142,14 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
{permissions.viewAllUsers && (
|
||||
<SidebarNavSubItem href="users">Users</SidebarNavSubItem>
|
||||
)}
|
||||
<SidebarNavSubItem href="notifications">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<span>Notifications</span>
|
||||
<FeatureStageBadge contentType="beta" size="sm" />
|
||||
</Stack>
|
||||
</SidebarNavSubItem>
|
||||
{permissions.viewNotificationTemplate && (
|
||||
<SidebarNavSubItem href="notifications">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<span>Notifications</span>
|
||||
<FeatureStageBadge contentType="beta" size="sm" />
|
||||
</Stack>
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
@ -167,7 +166,7 @@ interface OrganizationsSettingsNavigationProps {
|
||||
/** Organizations and their permissions or undefined if still fetching. */
|
||||
organizations: OrganizationWithPermissions[] | undefined;
|
||||
/** Site-wide permissions. */
|
||||
permissions: AuthorizationResponse;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -241,8 +240,6 @@ interface OrganizationSettingsNavigationProps {
|
||||
const OrganizationSettingsNavigation: FC<
|
||||
OrganizationSettingsNavigationProps
|
||||
> = ({ active, organization }) => {
|
||||
const { experiments } = useDashboard();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarNavItem
|
||||
|
@ -1,24 +1,19 @@
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
|
||||
|
||||
const ExternalAuthSettingsPage: FC = () => {
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("External Authentication Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
{deploymentValues ? (
|
||||
<ExternalAuthSettingsPageView config={deploymentValues.config} />
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
<ExternalAuthSettingsPageView config={deploymentConfig.config} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { deploymentDAUs } from "api/queries/deployment";
|
||||
import { entitlements } from "api/queries/entitlements";
|
||||
import { availableExperiments, experiments } from "api/queries/experiments";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
@ -11,7 +10,7 @@ import { pageTitle } from "utils/page";
|
||||
import { GeneralSettingsPageView } from "./GeneralSettingsPageView";
|
||||
|
||||
const GeneralSettingsPage: FC = () => {
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
const deploymentDAUsQuery = useQuery(deploymentDAUs());
|
||||
const safeExperimentsQuery = useQuery(availableExperiments());
|
||||
|
||||
@ -30,18 +29,14 @@ const GeneralSettingsPage: FC = () => {
|
||||
<Helmet>
|
||||
<title>{pageTitle("General Settings")}</title>
|
||||
</Helmet>
|
||||
{deploymentValues ? (
|
||||
<GeneralSettingsPageView
|
||||
deploymentOptions={deploymentValues.options}
|
||||
deploymentDAUs={deploymentDAUsQuery.data}
|
||||
deploymentDAUsError={deploymentDAUsQuery.error}
|
||||
entitlements={entitlementsQuery.data}
|
||||
invalidExperiments={invalidExperiments}
|
||||
safeExperiments={safeExperiments}
|
||||
/>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
<GeneralSettingsPageView
|
||||
deploymentOptions={deploymentConfig.options}
|
||||
deploymentDAUs={deploymentDAUsQuery.data}
|
||||
deploymentDAUsError={deploymentDAUsQuery.error}
|
||||
entitlements={entitlementsQuery.data}
|
||||
invalidExperiments={invalidExperiments}
|
||||
safeExperiments={safeExperiments}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,24 +1,19 @@
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { NetworkSettingsPageView } from "./NetworkSettingsPageView";
|
||||
|
||||
const NetworkSettingsPage: FC = () => {
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Network Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
{deploymentValues ? (
|
||||
<NetworkSettingsPageView options={deploymentValues.options} />
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
<NetworkSettingsPageView options={deploymentConfig.options} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -14,7 +14,7 @@ const meta: Meta<typeof NotificationEvents> = {
|
||||
defaultMethod: "smtp",
|
||||
availableMethods: ["smtp", "webhook"],
|
||||
templatesByGroup: selectTemplatesByGroup(MockNotificationTemplates),
|
||||
deploymentValues: baseMeta.parameters.deploymentValues,
|
||||
deploymentConfig: baseMeta.parameters.deploymentValues,
|
||||
},
|
||||
...baseMeta,
|
||||
};
|
||||
@ -25,7 +25,7 @@ type Story = StoryObj<typeof NotificationEvents>;
|
||||
|
||||
export const SMTPNotConfigured: Story = {
|
||||
args: {
|
||||
deploymentValues: {
|
||||
deploymentConfig: {
|
||||
notifications: {
|
||||
webhook: {
|
||||
endpoint: "https://example.com",
|
||||
@ -40,7 +40,7 @@ export const SMTPNotConfigured: Story = {
|
||||
|
||||
export const WebhookNotConfigured: Story = {
|
||||
args: {
|
||||
deploymentValues: {
|
||||
deploymentConfig: {
|
||||
notifications: {
|
||||
webhook: {
|
||||
endpoint: "",
|
||||
|
@ -31,20 +31,20 @@ type NotificationEventsProps = {
|
||||
defaultMethod: NotificationMethod;
|
||||
availableMethods: NotificationMethod[];
|
||||
templatesByGroup: ReturnType<typeof selectTemplatesByGroup>;
|
||||
deploymentValues: DeploymentValues;
|
||||
deploymentConfig: DeploymentValues;
|
||||
};
|
||||
|
||||
export const NotificationEvents: FC<NotificationEventsProps> = ({
|
||||
defaultMethod,
|
||||
availableMethods,
|
||||
templatesByGroup,
|
||||
deploymentValues,
|
||||
deploymentConfig,
|
||||
}) => {
|
||||
// Webhook
|
||||
const hasWebhookNotifications = Object.values(templatesByGroup)
|
||||
.flat()
|
||||
.some((t) => t.method === "webhook");
|
||||
const webhookValues = deploymentValues.notifications?.webhook ?? {};
|
||||
const webhookValues = deploymentConfig.notifications?.webhook ?? {};
|
||||
const isWebhookConfigured = requiredFieldsArePresent(webhookValues, [
|
||||
"endpoint",
|
||||
]);
|
||||
@ -53,7 +53,7 @@ export const NotificationEvents: FC<NotificationEventsProps> = ({
|
||||
const hasSMTPNotifications = Object.values(templatesByGroup)
|
||||
.flat()
|
||||
.some((t) => t.method === "smtp");
|
||||
const smtpValues = deploymentValues.notifications?.email ?? {};
|
||||
const smtpValues = deploymentConfig.notifications?.email ?? {};
|
||||
const isSMTPConfigured = requiredFieldsArePresent(smtpValues, [
|
||||
"smarthost",
|
||||
"from",
|
||||
|
@ -6,21 +6,20 @@ import {
|
||||
} from "api/queries/notifications";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import { castNotificationMethod } from "modules/notifications/utils";
|
||||
import { Section } from "pages/UserSettingsPage/Section";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQueries } from "react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { deploymentGroupHasParent } from "utils/deployOptions";
|
||||
import { pageTitle } from "utils/page";
|
||||
import OptionsTable from "../OptionsTable";
|
||||
import { NotificationEvents } from "./NotificationEvents";
|
||||
|
||||
export const NotificationsPage: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
const [templatesByGroup, dispatchMethods] = useQueries({
|
||||
queries: [
|
||||
{
|
||||
@ -30,10 +29,12 @@ export const NotificationsPage: FC = () => {
|
||||
notificationDispatchMethods(),
|
||||
],
|
||||
});
|
||||
const ready =
|
||||
templatesByGroup.data && dispatchMethods.data && deploymentValues;
|
||||
const tab = searchParams.get("tab") || "events";
|
||||
const tabState = useSearchParamsKey({
|
||||
key: "tab",
|
||||
defaultValue: "events",
|
||||
});
|
||||
|
||||
const ready = !!(templatesByGroup.data && dispatchMethods.data);
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@ -45,7 +46,7 @@ export const NotificationsPage: FC = () => {
|
||||
layout="fluid"
|
||||
featureStage={"beta"}
|
||||
>
|
||||
<Tabs active={tab}>
|
||||
<Tabs active={tabState.value}>
|
||||
<TabsList>
|
||||
<TabLink to="?tab=events" value="events">
|
||||
Events
|
||||
@ -58,10 +59,10 @@ export const NotificationsPage: FC = () => {
|
||||
|
||||
<div css={styles.content}>
|
||||
{ready ? (
|
||||
tab === "events" ? (
|
||||
tabState.value === "events" ? (
|
||||
<NotificationEvents
|
||||
templatesByGroup={templatesByGroup.data}
|
||||
deploymentValues={deploymentValues.config}
|
||||
deploymentConfig={deploymentConfig.config}
|
||||
defaultMethod={castNotificationMethod(
|
||||
dispatchMethods.data.default,
|
||||
)}
|
||||
@ -71,7 +72,7 @@ export const NotificationsPage: FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<OptionsTable
|
||||
options={deploymentValues?.options.filter((o) =>
|
||||
options={deploymentConfig.options.filter((o) =>
|
||||
deploymentGroupHasParent(o.group, "Notifications"),
|
||||
)}
|
||||
/>
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { ObservabilitySettingsPageView } from "./ObservabilitySettingsPageView";
|
||||
|
||||
const ObservabilitySettingsPage: FC = () => {
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
const { entitlements } = useDashboard();
|
||||
const { multiple_organizations: hasPremiumLicense } = useFeatureVisibility();
|
||||
|
||||
@ -17,16 +16,11 @@ const ObservabilitySettingsPage: FC = () => {
|
||||
<Helmet>
|
||||
<title>{pageTitle("Observability Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
{deploymentValues ? (
|
||||
<ObservabilitySettingsPageView
|
||||
options={deploymentValues.options}
|
||||
featureAuditLogEnabled={entitlements.features.audit_log.enabled}
|
||||
isPremium={hasPremiumLicense}
|
||||
/>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
<ObservabilitySettingsPageView
|
||||
options={deploymentConfig.options}
|
||||
featureAuditLogEnabled={entitlements.features.audit_log.enabled}
|
||||
isPremium={hasPremiumLicense}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { SecuritySettingsPageView } from "./SecuritySettingsPageView";
|
||||
|
||||
const SecuritySettingsPage: FC = () => {
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
const { entitlements } = useDashboard();
|
||||
|
||||
return (
|
||||
@ -15,15 +15,10 @@ const SecuritySettingsPage: FC = () => {
|
||||
<Helmet>
|
||||
<title>{pageTitle("Security Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
{deploymentValues ? (
|
||||
<SecuritySettingsPageView
|
||||
options={deploymentValues.options}
|
||||
featureBrowserOnlyEnabled={entitlements.features.browser_only.enabled}
|
||||
/>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
<SecuritySettingsPageView
|
||||
options={deploymentConfig.options}
|
||||
featureBrowserOnlyEnabled={entitlements.features.browser_only.enabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,24 +1,19 @@
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useManagementSettings } from "modules/management/ManagementSettingsLayout";
|
||||
import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { UserAuthSettingsPageView } from "./UserAuthSettingsPageView";
|
||||
|
||||
const UserAuthSettingsPage: FC = () => {
|
||||
const { deploymentValues } = useManagementSettings();
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("User Authentication Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
{deploymentValues ? (
|
||||
<UserAuthSettingsPageView options={deploymentValues.options} />
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
<UserAuthSettingsPageView options={deploymentConfig.options} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -19,6 +19,7 @@ import CreateEditRolePageView from "./CreateEditRolePageView";
|
||||
export const CreateEditRolePage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { organization: organizationName, roleName } = useParams() as {
|
||||
organization: string;
|
||||
roleName: string;
|
||||
|
@ -14,15 +14,10 @@ const OrganizationProvisionersPage: FC = () => {
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization: string;
|
||||
};
|
||||
const { organizations } = useManagementSettings();
|
||||
const { organization } = useManagementSettings();
|
||||
const { entitlements } = useDashboard();
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
|
||||
const organization = organizations
|
||||
? getOrganizationByName(organizations, organizationName)
|
||||
: undefined;
|
||||
const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName));
|
||||
|
||||
if (!organization) {
|
||||
@ -40,8 +35,3 @@ const OrganizationProvisionersPage: FC = () => {
|
||||
};
|
||||
|
||||
export default OrganizationProvisionersPage;
|
||||
|
||||
const getOrganizationByName = (
|
||||
organizations: readonly Organization[],
|
||||
name: string,
|
||||
) => organizations.find((org) => org.name === name);
|
||||
|
@ -1,6 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { MockDefaultOrganization, MockUser } from "testHelpers/entities";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization,
|
||||
MockOrganization2,
|
||||
MockUser,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
|
@ -35,10 +35,7 @@ const OrganizationSettingsPage: FC = () => {
|
||||
deleteOrganization(queryClient),
|
||||
);
|
||||
|
||||
const organization =
|
||||
organizations && organizationName
|
||||
? getOrganizationByName(organizations, organizationName)
|
||||
: undefined;
|
||||
const organization = organizations?.find((o) => o.name === organizationName);
|
||||
const permissionsQuery = useQuery(
|
||||
organizationsPermissions(organizations?.map((o) => o.id)),
|
||||
);
|
||||
@ -55,13 +52,10 @@ const OrganizationSettingsPage: FC = () => {
|
||||
// Redirect /organizations => /organizations/default-org, or if they cannot edit
|
||||
// the default org, then the first org they can edit, if any.
|
||||
if (!organizationName) {
|
||||
// .find will stop at the first match found; make sure default
|
||||
// organizations are placed first
|
||||
const editableOrg = [...organizations]
|
||||
.sort((a, b) => {
|
||||
// Prefer default org (it may not be first).
|
||||
// JavaScript will happily subtract booleans, but use numbers to keep
|
||||
// the compiler happy.
|
||||
return (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
|
||||
})
|
||||
.sort((a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0))
|
||||
.find((org) => canEditOrganization(permissions[org.id]));
|
||||
if (editableOrg) {
|
||||
return <Navigate to={`/organizations/${editableOrg.name}`} replace />;
|
||||
@ -111,10 +105,3 @@ const OrganizationSettingsPage: FC = () => {
|
||||
};
|
||||
|
||||
export default OrganizationSettingsPage;
|
||||
|
||||
const getOrganizationByName = (
|
||||
organizations: readonly Organization[],
|
||||
name: string,
|
||||
) => {
|
||||
return organizations.find((org) => org.name === name);
|
||||
};
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization,
|
||||
} from "testHelpers/entities";
|
||||
import { withManagementSettingsProvider } from "testHelpers/storybook";
|
||||
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
|
||||
|
||||
const meta: Meta<typeof OrganizationSettingsPageView> = {
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
import { Loader } from "./components/Loader/Loader";
|
||||
import { RequireAuth } from "./contexts/auth/RequireAuth";
|
||||
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
|
||||
import { ManagementSettingsLayout } from "./modules/management/ManagementSettingsLayout";
|
||||
import AuditPage from "./pages/AuditPage/AuditPage";
|
||||
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
|
||||
import LoginPage from "./pages/LoginPage/LoginPage";
|
||||
@ -28,6 +27,12 @@ import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage";
|
||||
// - Pages that are secondary, not in the main navigation or not usually accessed
|
||||
// - Pages that use heavy dependencies like charts or time libraries
|
||||
const NotFoundPage = lazy(() => import("./pages/404Page/404Page"));
|
||||
const ManagementSettingsLayout = lazy(
|
||||
() => import("./modules/management/ManagementSettingsLayout"),
|
||||
);
|
||||
const DeploymentSettingsProvider = lazy(
|
||||
() => import("./modules/management/DeploymentSettingsProvider"),
|
||||
);
|
||||
const CliAuthenticationPage = lazy(
|
||||
() => import("./pages/CliAuthPage/CliAuthPage"),
|
||||
);
|
||||
@ -427,22 +432,32 @@ export const router = createBrowserRouter(
|
||||
</Route>
|
||||
|
||||
<Route path="/deployment" element={<ManagementSettingsLayout />}>
|
||||
<Route path="general" element={<GeneralSettingsPage />} />
|
||||
<Route path="licenses" element={<LicensesSettingsPage />} />
|
||||
<Route path="licenses/add" element={<AddNewLicensePage />} />
|
||||
<Route path="security" element={<SecuritySettingsPage />} />
|
||||
<Route
|
||||
path="observability"
|
||||
element={<ObservabilitySettingsPage />}
|
||||
/>
|
||||
<Route path="appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="network" element={<NetworkSettingsPage />} />
|
||||
<Route path="userauth" element={<UserAuthSettingsPage />} />
|
||||
<Route
|
||||
path="external-auth"
|
||||
element={<ExternalAuthSettingsPage />}
|
||||
/>
|
||||
<Route element={<DeploymentSettingsProvider />}>
|
||||
<Route path="general" element={<GeneralSettingsPage />} />
|
||||
<Route path="security" element={<SecuritySettingsPage />} />
|
||||
<Route
|
||||
path="observability"
|
||||
element={<ObservabilitySettingsPage />}
|
||||
/>
|
||||
<Route path="network" element={<NetworkSettingsPage />} />
|
||||
<Route path="userauth" element={<UserAuthSettingsPage />} />
|
||||
<Route
|
||||
path="external-auth"
|
||||
element={<ExternalAuthSettingsPage />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="notifications"
|
||||
element={<DeploymentNotificationsPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="licenses">
|
||||
<Route index element={<LicensesSettingsPage />} />
|
||||
<Route path="add" element={<AddNewLicensePage />} />
|
||||
</Route>
|
||||
<Route path="appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="workspace-proxies" element={<WorkspaceProxyPage />} />
|
||||
<Route path="oauth2-provider">
|
||||
<Route index element={<NotFoundPage />} />
|
||||
<Route path="apps">
|
||||
@ -452,14 +467,9 @@ export const router = createBrowserRouter(
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="workspace-proxies" element={<WorkspaceProxyPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/create" element={<CreateUserPage />} />
|
||||
{groupsRouter()}
|
||||
<Route
|
||||
path="notifications"
|
||||
element={<DeploymentNotificationsPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/settings" element={<UserSettingsLayout />}>
|
||||
|
@ -2766,12 +2766,37 @@ export const MockPermissions: Permissions = {
|
||||
viewUpdateCheck: true,
|
||||
viewDeploymentStats: true,
|
||||
viewExternalAuthConfig: true,
|
||||
readWorkspaceProxies: true,
|
||||
editWorkspaceProxies: true,
|
||||
createOrganization: true,
|
||||
editAnyOrganization: true,
|
||||
viewAnyGroup: true,
|
||||
createGroup: true,
|
||||
viewAllLicenses: true,
|
||||
viewNotificationTemplate: true,
|
||||
};
|
||||
|
||||
export const MockNoPermissions: Permissions = {
|
||||
createTemplates: false,
|
||||
createUser: false,
|
||||
deleteTemplates: false,
|
||||
updateTemplates: false,
|
||||
viewAllUsers: false,
|
||||
updateUsers: false,
|
||||
viewAnyAuditLog: false,
|
||||
viewDeploymentValues: false,
|
||||
editDeploymentValues: false,
|
||||
viewUpdateCheck: false,
|
||||
viewDeploymentStats: false,
|
||||
viewExternalAuthConfig: false,
|
||||
readWorkspaceProxies: false,
|
||||
editWorkspaceProxies: false,
|
||||
createOrganization: false,
|
||||
editAnyOrganization: false,
|
||||
viewAnyGroup: false,
|
||||
createGroup: false,
|
||||
viewAllLicenses: false,
|
||||
viewNotificationTemplate: false,
|
||||
};
|
||||
|
||||
export const MockDeploymentConfig: DeploymentConfig = {
|
||||
|
@ -9,7 +9,7 @@ import { ThemeProvider } from "contexts/ThemeProvider";
|
||||
import { RequireAuth } from "contexts/auth/RequireAuth";
|
||||
import { DashboardLayout } from "modules/dashboard/DashboardLayout";
|
||||
import type { DashboardProvider } from "modules/dashboard/DashboardProvider";
|
||||
import { ManagementSettingsLayout } from "modules/management/ManagementSettingsLayout";
|
||||
import ManagementSettingsLayout from "modules/management/ManagementSettingsLayout";
|
||||
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
|
||||
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
import type { ReactNode } from "react";
|
||||
|
@ -7,6 +7,7 @@ import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
|
||||
import { AuthProvider } from "contexts/auth/AuthProvider";
|
||||
import { permissionsToCheck } from "contexts/auth/permissions";
|
||||
import { DashboardContext } from "modules/dashboard/DashboardProvider";
|
||||
import { DeploymentSettingsContext } from "modules/management/DeploymentSettingsProvider";
|
||||
import { ManagementSettingsContext } from "modules/management/ManagementSettingsLayout";
|
||||
import type { FC } from "react";
|
||||
import { useQueryClient } from "react-query";
|
||||
@ -131,12 +132,15 @@ export const withManagementSettingsProvider = (Story: FC) => {
|
||||
return (
|
||||
<ManagementSettingsContext.Provider
|
||||
value={{
|
||||
deploymentValues: MockDeploymentConfig,
|
||||
organizations: [MockDefaultOrganization],
|
||||
organization: MockDefaultOrganization,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
<DeploymentSettingsContext.Provider
|
||||
value={{ deploymentConfig: MockDeploymentConfig }}
|
||||
>
|
||||
<Story />
|
||||
</DeploymentSettingsContext.Provider>
|
||||
</ManagementSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user