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:
Michael Smith
2024-10-29 00:06:33 -05:00
committed by GitHub
parent fd60e1c2ba
commit 1d925ab043
26 changed files with 243 additions and 189 deletions

View File

@ -175,6 +175,7 @@
"unauthenticate",
"unconvert",
"untar",
"userauth",
"userspace",
"VMID",
"walkthrough",

View File

@ -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);

View File

@ -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 {};

View File

@ -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>;

View 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;

View File

@ -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;

View File

@ -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,
},
};

View File

@ -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

View File

@ -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} />
</>
);
};

View File

@ -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}
/>
</>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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: "",

View File

@ -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",

View File

@ -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"),
)}
/>

View File

@ -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}
/>
</>
);
};

View File

@ -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}
/>
</>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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;

View File

@ -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);

View File

@ -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,

View File

@ -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);
};

View File

@ -4,7 +4,6 @@ import {
MockDefaultOrganization,
MockOrganization,
} from "testHelpers/entities";
import { withManagementSettingsProvider } from "testHelpers/storybook";
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
const meta: Meta<typeof OrganizationSettingsPageView> = {

View File

@ -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 />}>

View File

@ -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 = {

View File

@ -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";

View File

@ -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>
);
};