Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disable UI starting dataset creating jobs for unprivileged users #7753

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, this prop can be added to a few other components:

path="/users/:userId/details"
path="/users"
path="/teams"
path="/timetracking"
path="/reports/projectProgress"
path="/reports/availableTasks"
path="/tasks"
path="/tasks/create"
path="/tasks/:taskId/edit"
path="/tasks/:taskId"
path="/projects"
path="/projects/create"
path="/projects/:projectId/tasks"
path="/projects/:projectId/edit"
path="/datasets/:organizationName/:datasetName/import"
path="/datasets/:organizationName/:datasetName/edit"
path="/taskTypes"
path="/taskTypes/create"
path="/taskTypes/:taskTypeId/edit"
path="/taskTypes/:taskTypeId/tasks"
path="/taskTypes/:taskTypeId/projects"
path="/scripts/create"
path="/scripts/:scriptId/edit"
path="/scripts"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for listing all other components 👍

Initially, I though that this should be added in a separate PR but doing it already here is also fine to me and might also save some time.

I'll test each route as admin and non-admin later :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll test each route as admin and non-admin later :)

Done, worked out as expected 👍. All pages/view listed above are available to sample@scm.io but not to sample2@scm.io

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