Skip to content

Commit

Permalink
Disable UI starting dataset creating jobs for unprivileged users (#7753)
Browse files Browse the repository at this point in the history
* disable ui starting dataset creating jobs for non-admin/ datasetmanager users

* hide materialize merger mode button instead of disabling it

* check for is team manager as well (and not only admin or dataset manager roles)

* re-allow merge with fallback layer for team managers

* add changelog entry

* add improved image to permission enforcer view / forbidden view

* add manager/admin permissions required view to privileged views
  • Loading branch information
MichaelBuessemeyer committed May 2, 2024
1 parent 098413d commit 7af1a4c
Show file tree
Hide file tree
Showing 13 changed files with 674 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Added

### Changed
- Non admin or manager user can no longer start long running jobs creating datasets. This includes annotation materialization and AI inferrals. [#7753](https://github.com/scalableminds/webknossos/pull/7753)

### Fixed

Expand Down
30 changes: 30 additions & 0 deletions frontend/javascripts/components/permission_enforcer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from "react";
import { Button, Result, Col, Row } from "antd";
import { Link } from "react-router-dom";

export function PageNotAvailableToNormalUser() {
return (
<Row justify="center" align="middle" className="full-viewport-height">
<Col>
<Result
status="error"
title="Forbidden"
icon={<i className="drawing drawing-forbidden-view" />}
subTitle={
<>
Apologies, but you don't have permission to view this page.
<br />
Please reach out to a team manager, dataset manager or administrator to assist you
with the actions you'd like to take.
</>
}
extra={
<Link to="/">
<Button type="primary">Return to Dashboard</Button>
</Link>
}
/>
</Col>
</Row>
);
}
11 changes: 10 additions & 1 deletion frontend/javascripts/components/secured_route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
isFeatureAllowedByPricingPlan,
PricingPlanEnum,
} from "admin/organization/pricing_plan_utils";
import { APIOrganization } from "types/api_flow_types";
import { APIOrganization, APIUser } from "types/api_flow_types";
import { PageUnavailableForYourPlanView } from "components/pricing_enforcers";
import type { ComponentType } from "react";
import { isUserAdminOrManager } from "libs/utils";
import type { RouteComponentProps } from "react-router-dom";
import type { OxalisState } from "oxalis/store";
import { PageNotAvailableToNormalUser } from "./permission_enforcer";

type StateProps = {
activeOrganization: APIOrganization | null;
activeUser: APIUser | null | undefined;
};
export type SecuredRouteProps = RouteComponentProps &
StateProps & {
Expand All @@ -22,6 +25,7 @@ export type SecuredRouteProps = RouteComponentProps &
render?: (arg0: RouteComponentProps) => React.ReactNode;
isAuthenticated: boolean;
requiredPricingPlan?: PricingPlanEnum;
requiresAdminOrManagerRole?: boolean;
serverAuthenticationCallback?: (...args: Array<any>) => any;
exact?: boolean;
};
Expand Down Expand Up @@ -62,6 +66,7 @@ class SecuredRoute extends React.PureComponent<SecuredRouteProps, State> {
const isCompletelyAuthenticated = serverAuthenticationCallback
? isAuthenticated || this.state.isAdditionallyAuthenticated
: isAuthenticated;
const isAdminOrManager = this.props.activeUser && isUserAdminOrManager(this.props.activeUser);
return (
<Route
{...rest}
Expand All @@ -83,6 +88,9 @@ class SecuredRoute extends React.PureComponent<SecuredRouteProps, State> {
/>
);
}
if (this.props.requiresAdminOrManagerRole && !isAdminOrManager) {
return <PageNotAvailableToNormalUser />;
}

if (Component != null) {
return <Component />;
Expand All @@ -98,6 +106,7 @@ class SecuredRoute extends React.PureComponent<SecuredRouteProps, State> {
}
const mapStateToProps = (state: OxalisState): StateProps => ({
activeOrganization: state.activeOrganization,
activeUser: state.activeUser,
});

const connector = connect(mapStateToProps);
Expand Down
8 changes: 7 additions & 1 deletion frontend/javascripts/libs/react_helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from "react";
import { useStore } from "react-redux";
import { useSelector, useStore } from "react-redux";
import type { OxalisState } from "oxalis/store";
import { ArbitraryFunction } from "types/globals";
import { isUserAdminOrManager } from "libs/utils";

// From https://overreacted.io/making-setinterval-declarative-with-react-hooks/
export function useInterval(
Expand Down Expand Up @@ -88,4 +89,9 @@ export function makeComponentLazy<T extends { isOpen: boolean }>(
};
}

export function useIsActiveUserAdminOrManager() {
const user = useSelector((state: OxalisState) => state.activeUser);
return user != null && isUserAdminOrManager(user);
}

export default {};
2 changes: 1 addition & 1 deletion frontend/javascripts/oxalis/model/actions/ui_actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AnnotationTool, Vector3 } from "oxalis/constants";
import type { OxalisState, BorderOpenStatus, Theme } from "oxalis/store";
import { StartAIJobModalState } from "oxalis/view/action-bar/starting_job_modals";
import type { StartAIJobModalState } from "oxalis/view/action-bar/starting_job_modals";

type SetDropzoneModalVisibilityAction = ReturnType<typeof setDropzoneModalVisibilityAction>;
type SetVersionRestoreVisibilityAction = ReturnType<typeof setVersionRestoreVisibilityAction>;
Expand Down
4 changes: 3 additions & 1 deletion frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { MenuInfo } from "rc-menu/lib/interface";
import { getViewportExtents } from "oxalis/model/accessors/view_mode_accessor";
import { ensureLayerMappingsAreLoadedAction } from "oxalis/model/actions/dataset_actions";
import { APIJobType } from "types/api_flow_types";
import { useIsActiveUserAdminOrManager } from "libs/react_helpers";

const NARROW_BUTTON_STYLE = {
paddingLeft: 10,
Expand Down Expand Up @@ -406,6 +407,7 @@ function AdditionalSkeletonModesButtons() {
(state: OxalisState) => state.userConfiguration.newNodeNewTree,
);
const dataset = useSelector((state: OxalisState) => state.dataset);
const isUserAdminOrManager = useIsActiveUserAdminOrManager();

const segmentationTracingLayer = useSelector((state: OxalisState) =>
getActiveSegmentationTracing(state),
Expand Down Expand Up @@ -464,7 +466,7 @@ function AdditionalSkeletonModesButtons() {
alt="Merger Mode"
/>
</ButtonComponent>
{isMergerModeEnabled && isMaterializeVolumeAnnotationEnabled && (
{isMergerModeEnabled && isMaterializeVolumeAnnotationEnabled && isUserAdminOrManager && (
<ButtonComponent
style={NARROW_BUTTON_STYLE}
onClick={() => setShowMaterializeVolumeAnnotationModal(true)}
Expand Down
9 changes: 7 additions & 2 deletions frontend/javascripts/oxalis/view/action_bar_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import { ArbitraryVectorInput } from "libs/vector_input";
import { APIJobType, type AdditionalCoordinate } from "types/api_flow_types";
import ButtonComponent from "./components/button_component";
import { setAIJobModalStateAction } from "oxalis/model/actions/ui_actions";
import { StartAIJobModalState, StartAIJobModal } from "./action-bar/starting_job_modals";
import { type StartAIJobModalState, StartAIJobModal } from "./action-bar/starting_job_modals";
import { isUserAdminOrTeamManager } from "libs/utils";

const VersionRestoreWarning = (
<Alert
Expand Down Expand Up @@ -245,7 +246,9 @@ class ActionBarView extends React.PureComponent<Props, State> {
hasSkeleton,
layoutProps,
viewMode,
activeUser,
} = this.props;
const isAdminOrDatasetManager = activeUser && isUserAdminOrTeamManager(activeUser);
const isViewMode = controlMode === ControlModeEnum.VIEW;
const isArbitrarySupported = hasSkeleton || isViewMode;
const isAIAnalysisEnabled = () => {
Expand Down Expand Up @@ -281,7 +284,9 @@ class ActionBarView extends React.PureComponent<Props, State> {
<DatasetPositionView />
<AdditionalCoordinatesInputView />
{isArbitrarySupported && !is2d ? <ViewModesView /> : null}
{isAIAnalysisEnabled() ? this.renderStartAIJobButton(!datasetHasColorLayer) : null}
{isAIAnalysisEnabled() && isAdminOrDatasetManager
? this.renderStartAIJobButton(!datasetHasColorLayer)
: null}
{!isReadOnly && constants.MODES_PLANE.indexOf(viewMode) > -1 ? <ToolbarView /> : null}
{isViewMode ? this.renderStartTracingButton() : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ type DatasetSettingsProps = {
controlMode: ControlMode;
isArbitraryMode: boolean;
isAdminOrDatasetManager: boolean;
isAdminOrManager: boolean;
isSuperUser: boolean;
};

Expand Down Expand Up @@ -558,7 +559,7 @@ class DatasetSettings extends React.PureComponent<DatasetSettingsProps, State> {
layerSettings: DatasetLayerConfiguration,
hasLessThanTwoColorLayers: boolean = true,
) => {
const { tracing, dataset } = this.props;
const { tracing, dataset, isAdminOrManager } = this.props;
const { intensityRange } = layerSettings;
const layer = getLayerByName(dataset, layerName);
const isSegmentation = layer.category === "segmentation";
Expand Down Expand Up @@ -611,7 +612,7 @@ class DatasetSettings extends React.PureComponent<DatasetSettingsProps, State> {
readableName,
);
const possibleItems: MenuProps["items"] = [
isVolumeTracing && !isDisabled && maybeFallbackLayer != null
isVolumeTracing && !isDisabled && maybeFallbackLayer != null && isAdminOrManager
? {
label: this.getMergeWithFallbackLayerButton(layer),
key: "mergeWithFallbackLayerButton",
Expand Down Expand Up @@ -1502,6 +1503,7 @@ const mapStateToProps = (state: OxalisState) => ({
isArbitraryMode: Constants.MODES_ARBITRARY.includes(state.temporaryConfiguration.viewMode),
isAdminOrDatasetManager:
state.activeUser != null ? Utils.isUserAdminOrDatasetManager(state.activeUser) : false,
isAdminOrManager: state.activeUser != null ? Utils.isUserAdminOrManager(state.activeUser) : false,
isSuperUser: state.activeUser?.isSuperUser || false,
});

Expand Down
25 changes: 25 additions & 0 deletions frontend/javascripts/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ class ReactRouter extends React.Component<Props> {
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/users/:userId/details"
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<DashboardView
userId={match.params.userId}
Expand All @@ -304,22 +305,26 @@ class ReactRouter extends React.Component<Props> {
isAuthenticated={isAuthenticated}
path="/users"
component={UserListView}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/teams"
component={TeamListView}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/timetracking"
component={TimeTrackingOverview}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
requiredPricingPlan={PricingPlanEnum.Team}
path="/reports/projectProgress"
component={ProjectProgressReportView}
requiresAdminOrManagerRole
exact
/>
<RouteWithErrorBoundary
Expand All @@ -331,25 +336,29 @@ class ReactRouter extends React.Component<Props> {
requiredPricingPlan={PricingPlanEnum.Team}
path="/reports/availableTasks"
component={AvailableTasksReportView}
requiresAdminOrManagerRole
exact
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/tasks"
requiredPricingPlan={PricingPlanEnum.Team}
component={TaskListView}
requiresAdminOrManagerRole
exact
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/tasks/create"
requiredPricingPlan={PricingPlanEnum.Team}
component={TaskCreateView}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/tasks/:taskId/edit"
requiredPricingPlan={PricingPlanEnum.Team}
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<TaskCreateFormView taskId={match.params.taskId} />
)}
Expand All @@ -358,6 +367,7 @@ class ReactRouter extends React.Component<Props> {
isAuthenticated={isAuthenticated}
path="/tasks/:taskId"
requiredPricingPlan={PricingPlanEnum.Team}
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<TaskListView
initialFieldValues={{
Expand All @@ -370,6 +380,7 @@ class ReactRouter extends React.Component<Props> {
isAuthenticated={isAuthenticated}
path="/projects"
requiredPricingPlan={PricingPlanEnum.Team}
requiresAdminOrManagerRole
render={(
{ location }: ContextRouter, // Strip the leading # away. If there is no hash, "".slice(1) will evaluate to "", too.
) => <ProjectListView initialSearchValue={location.hash.slice(1)} />}
Expand All @@ -379,12 +390,14 @@ class ReactRouter extends React.Component<Props> {
isAuthenticated={isAuthenticated}
path="/projects/create"
requiredPricingPlan={PricingPlanEnum.Team}
requiresAdminOrManagerRole
render={() => <ProjectCreateView />}
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/projects/:projectId/tasks"
requiredPricingPlan={PricingPlanEnum.Team}
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<TaskListView
initialFieldValues={{
Expand All @@ -397,6 +410,7 @@ class ReactRouter extends React.Component<Props> {
isAuthenticated={isAuthenticated}
path="/projects/:projectId/edit"
requiredPricingPlan={PricingPlanEnum.Team}
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<ProjectCreateView projectId={match.params.projectId} />
)}
Expand Down Expand Up @@ -428,11 +442,13 @@ class ReactRouter extends React.Component<Props> {
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/datasets/upload"
requiresAdminOrManagerRole
render={() => <DatasetAddView />}
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/datasets/:organizationName/:datasetName/import"
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<DatasetSettingsView
isEditingMode={false}
Expand All @@ -450,6 +466,7 @@ class ReactRouter extends React.Component<Props> {
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/datasets/:organizationName/:datasetName/edit"
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<DatasetSettingsView
isEditingMode
Expand All @@ -465,6 +482,7 @@ class ReactRouter extends React.Component<Props> {
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/taskTypes"
requiresAdminOrManagerRole
render={(
{ location }: ContextRouter, // Strip the leading # away. If there is no hash, "".slice(1) will evaluate to "", too.
) => <TaskTypeListView initialSearchValue={location.hash.slice(1)} />}
Expand All @@ -475,6 +493,7 @@ class ReactRouter extends React.Component<Props> {
path="/taskTypes/create"
requiredPricingPlan={PricingPlanEnum.Team}
component={TaskTypeCreateView}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
Expand All @@ -483,6 +502,7 @@ class ReactRouter extends React.Component<Props> {
render={({ match }: ContextRouter) => (
<TaskTypeCreateView taskTypeId={match.params.taskTypeId} />
)}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
Expand All @@ -495,6 +515,7 @@ class ReactRouter extends React.Component<Props> {
}}
/>
)}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
Expand All @@ -503,15 +524,18 @@ class ReactRouter extends React.Component<Props> {
render={({ match }: ContextRouter) => (
<ProjectListView taskTypeId={match.params.taskTypeId || ""} />
)}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/scripts/create"
render={() => <ScriptCreateView />}
requiresAdminOrManagerRole
/>
<SecuredRouteWithErrorBoundary
isAuthenticated={isAuthenticated}
path="/scripts/:scriptId/edit"
requiresAdminOrManagerRole
render={({ match }: ContextRouter) => (
<ScriptCreateView scriptId={match.params.scriptId} />
)}
Expand All @@ -520,6 +544,7 @@ class ReactRouter extends React.Component<Props> {
isAuthenticated={isAuthenticated}
path="/scripts"
component={ScriptListView}
requiresAdminOrManagerRole
exact
/>
<SecuredRouteWithErrorBoundary
Expand Down
Loading

0 comments on commit 7af1a4c

Please sign in to comment.