Skip to content

Commit

Permalink
Permission system (#304)
Browse files Browse the repository at this point in the history
* started working on permission system ui

* find existing users

* prepared project settings type

* use state

* implemented checkboxes

* finished modifying project permissions

* translations and infobox added

* implemented logic

* readme update

* document default permissions

* cleanup after merge

* fix
  • Loading branch information
foxriver76 authored Jan 12, 2024
1 parent 9fbeb25 commit d9c42fb
Show file tree
Hide file tree
Showing 18 changed files with 480 additions and 147 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@

WEB visualisation for ioBroker platform.

## Overview
- [License requirements](#license-requirements)
- [Installation & Documentation](#installation--documentation)
- [Bindings of objects](#bindings-of-objects)
- [Filters](#filters)
- [Control interface](#control-interface)
- [Default view](#default-view)
- [Permissions System](#permissions-system)
- [Settings](#settings)
- [SVG and curentColor](#svg-and-currentcolor)


## License requirements
To use this adapter in `ioBroker` you need to accept the source code license of the adapter. The source code of this adapter is available under the CC BY-NC license.

Expand Down Expand Up @@ -194,6 +206,14 @@ E.g., you can create two views "Landscape-Mobile" and "Portrait-Mobile" and thes

There is a helper widget "basic - Screen Resolution" that shows actual screen resolution and best suitable default view for this resolution.

## Permissions System
In the project management dialog, you can configure `read` and `write` permissions for each ioBroker user.

The `read` flag means, that the project is accessible for this user in the Runtime.
The `write` flag means, that the project is accessible for this user in the Edit Mode.

When a new user is created via ioBroker Admin adapter, it will have both permissions by default.

## Settings
### Reload if sleep longer than
There is a rule that after some disconnection period, the whole VIS page will be reloaded to synchronize the project.
Expand Down Expand Up @@ -230,6 +250,9 @@ E.g., if it was used in a menu and the menu is red, the circle would be red.
### **WORK IN PROGRESS**
-->
## Changelog
### **WORK IN PROGRESS**
* (foxriver76) dedicated permission system on project level introduced

### 2.9.16 (2024-01-11)
* (foxriver76) use the correct fallback values for widget signals determination

Expand Down
25 changes: 23 additions & 2 deletions src/src/Runtime.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
} from './Vis/visUtils';
import VisWidgetsCatalog from './Vis/visWidgetsCatalog';

import { store, updateProject } from './Store';
import { store, updateActiveUser, updateProject } from './Store';
import { hasProjectAccess } from './Utils/utils';

const generateClassName = createGenerateClassName({
productionPrefix: 'vis-r',
Expand Down Expand Up @@ -633,7 +634,11 @@ class Runtime extends GenericApp {
}

const userName = await this.socket.getCurrentUser(); // just name, like "admin"

const currentUser = await this.socket.getObject(`system.user.${userName || 'admin'}`);

store.dispatch(updateActiveUser(currentUser.common.name));

const groups = await this.socket.getGroups();
const userGroups = {};
groups.forEach(group => userGroups[group._id] = group);
Expand Down Expand Up @@ -889,6 +894,8 @@ class Runtime extends GenericApp {
}

showSmallProjectsDialog() {
const { visProject, activeUser } = store.getState();

return <Dialog
open={!0}
maxWidth="sm"
Expand All @@ -909,7 +916,7 @@ class Runtime extends GenericApp {
</div> : null}
<MenuList>
{this.state.projects.map(project =>
<ListItemButton key={project} onClick={() => window.location.href = `?${project}`}>
<ListItemButton key={project} onClick={() => window.location.href = `?${project}`} disabled={!hasProjectAccess({ editMode: this.state.editMode, project: visProject, user: activeUser })}>
<ListItemIcon>
<IconDocument />
</ListItemIcon>
Expand Down Expand Up @@ -969,6 +976,20 @@ class Runtime extends GenericApp {
return this.showSmallProjectsDialog();
}

const { visProject, activeUser } = store.getState();

if (!hasProjectAccess({ editMode: this.state.editMode, project: visProject, user: activeUser })) {
console.warn(`User ${activeUser} has no permissions for ${this.state.editMode ? 'edit mode' : 'runtime'} of project ${this.state.projectName}`);
if (this.state.projects) {
return this.showSmallProjectsDialog();
}

this.refreshProjects().then(() => {
this.setState({ showProjectsDialog: true });
});
return null;
}

return <VisEngine
key={this.state.projectName}
widgetsLoaded={this.state.widgetsLoaded}
Expand Down
7 changes: 6 additions & 1 deletion src/src/Store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ export const updateProject = createAction<Project>('project/update');
export const updateView = createAction<{ viewId: string; data: View }>('view/update');
export const updateWidget = createAction<{ viewId: string; widgetId: SingleWidgetId; data: SingleWidget }>('widget/update');
export const updateGroupWidget = createAction<{ viewId: string; widgetId: GroupWidgetId; data: GroupWidget }>('group/update');
export const updateActiveUser = createAction<string>('activeUser/update');
export const recalculateFields = createAction<boolean>('attributes/recalculate');

const initialState = {
visProject: {} as Project,
recalculateFields: false,
activeUser: '',
};

const reducer = createReducer(
Expand All @@ -35,6 +37,9 @@ const reducer = createReducer(
const { viewId, widgetId, data } = action.payload;
state.visProject[viewId].widgets[widgetId] = data;
})
.addCase(updateActiveUser, (state, action) => {
state.activeUser = action.payload;
})
.addCase(recalculateFields, (state, action) => {
state.recalculateFields = action.payload;
});
Expand All @@ -43,7 +48,7 @@ const reducer = createReducer(

type StoreState = typeof initialState

const selectProject = (state: StoreState) => state.visProject;
export const selectProject = (state: StoreState) => state.visProject;

export const selectView = createSelector([
selectProject,
Expand Down
155 changes: 155 additions & 0 deletions src/src/Toolbar/ProjectsManager/PermissionsDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React from 'react';

import {
Check as SaveIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import {
Checkbox,
} from '@mui/material';
import { type Connection, I18n } from '@iobroker/adapter-react-v5';
import { Permissions, Project } from '@/types';
import { store } from '@/Store';
import { deepClone, DEFAULT_PERMISSIONS } from '@/Utils/utils';
import IODialog from '../../Components/IODialog';

interface PermissionsDialogProps {
/** Function called when dialog is closed */
onClose: () => void;
/** Modify the active project */
changeProject: (project: Project) => void;
/** The socket connection */
socket: Connection;
}

interface PermissionsDialogState {
/** Contains all existing users */
users: string[];
/** Permissions for each user for the current project */
projectPermissions: Map<string, Permissions>;
}

export default class PermissionsDialog extends React.Component<PermissionsDialogProps, PermissionsDialogState> {
/** Admin user cannot be disabled */
private readonly ADMIN_USER = 'admin';

constructor(props: PermissionsDialogProps) {
super(props);

this.state = {
users: [],
projectPermissions: new Map(),
};
}

/**
* Lifecycle hook called when component is mounted
*/
async componentDidMount(): Promise<void> {
const userView: Record<string, ioBroker.UserObject> = await this.props.socket.getObjectViewSystem('user', 'system.user.', 'system.user.\u9999');
const { visProject } = store.getState();
const projectPermissions = new Map<string, Permissions>();

for (const user of Object.keys(userView)) {
projectPermissions.set(user, visProject.___settings.permissions?.[user] ?? DEFAULT_PERMISSIONS);
}

this.setState({ users: Object.keys(userView), projectPermissions });
}

/**
* On save temporary values are set to the store
*/
onSave(): void {
const project = deepClone(store.getState().visProject);

if (project.___settings.permissions === undefined) {
project.___settings.permissions = {};
}

for (const [user, permissions] of this.state.projectPermissions) {
if (user === this.ADMIN_USER) {
continue;
}

project.___settings.permissions[user] = permissions;
}

this.props.changeProject(project);
this.props.onClose();
}

/**
* Render the info dialog
*/
renderInfoDialog(): React.JSX.Element {
return <div style={{
display: 'inline-flex', alignItems: 'center', border: '1px solid', borderRadius: '5px', padding: '2px',
}}
>
<InfoIcon />
<div style={{ margin: '6px', fontSize: '12px' }}>
<p style={{ margin: 0 }}>
{I18n.t('Only the admin user can change permissions')}
<br />
{I18n.t('Read = Runtime access')}
<br />
{I18n.t('Write = Edit mode access')}
</p>
</div>
</div>;
}

/**
* Render the actual component
*/
render(): React.JSX.Element {
const { activeUser } = store.getState();

return <IODialog
title="Permissions"
open={!0}
onClose={() => this.props.onClose()}
actionNoClose
action={() => this.onSave()}
actionTitle="Save"
ActionIcon={SaveIcon}
actionDisabled={false}
closeDisabled={false}
>
{this.renderInfoDialog()}
{this.state.users.map(user => <div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
key={user}
>
<div style={{ display: 'inline' }}>{`${user}:`}</div>
<div>
<Checkbox
disabled={user === this.ADMIN_USER || activeUser !== this.ADMIN_USER}
checked={this.state.projectPermissions.get(user)?.read}
onClick={() => {
const newState = this.state;
const currVal = this.state.projectPermissions.get(user);

newState.projectPermissions.set(user, { read: !currVal?.read, write: !!currVal?.write });
this.setState(newState);
}}
/>
{I18n.t('Read')}
<Checkbox
disabled={user === this.ADMIN_USER || activeUser !== this.ADMIN_USER}
checked={this.state.projectPermissions.get(user)?.write}
onClick={() => {
const newState = this.state;
const currVal = this.state.projectPermissions.get(user);

newState.projectPermissions.set(user, { read: !!currVal?.read, write: !currVal?.write });
this.setState(newState);
}}
/>
{I18n.t('Write')}
</div>
</div>)}
</IODialog>;
}
}
20 changes: 20 additions & 0 deletions src/src/Toolbar/ProjectsManager/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Edit as EditIcon,
Delete as DeleteIcon,
FileCopy as IconDocument,
Person as PermissionsIcon,
} from '@mui/icons-material';
import { BiImport, BiExport } from 'react-icons/bi';

Expand All @@ -18,6 +19,7 @@ import { I18n, Utils } from '@iobroker/adapter-react-v5';
import IODialog from '../../Components/IODialog';
import ImportProjectDialog, { getLiveHost } from './ImportProjectDialog';
import ProjectDialog from './ProjectDialog';
import PermissionsDialog from './PermissionsDialog';

const styles = theme => ({
projectBlock: {
Expand Down Expand Up @@ -65,6 +67,7 @@ const ProjectsManage = props => {
const [dialogName, setDialogName] = useState('');
const [dialogProject, setDialogProject] = useState(null);
const [showExportDialog, setShowExportDialog] = useState(null);
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [working, setWorking] = useState(false);

Expand Down Expand Up @@ -185,6 +188,18 @@ const ProjectsManage = props => {
{projectName}
</Button>
<span className={props.classes.viewManageButtonActions}>
<Tooltip title={I18n.t('Permissions')} classes={{ popper: props.classes.tooltip }}>
{working === projectName ? <CircularProgress size={22} /> :
<IconButton
onClick={event => {
setAnchorEl(event.currentTarget);
setShowPermissionsDialog(projectName);
}}
size="small"
>
<PermissionsIcon fontSize="20" />
</IconButton>}
</Tooltip>
<Tooltip title={I18n.t('Export')} classes={{ popper: props.classes.tooltip }}>
{working === projectName ? <CircularProgress size={22} /> :
<IconButton
Expand Down Expand Up @@ -229,6 +244,11 @@ const ProjectsManage = props => {
{...props}
classes={{}}
/> : null}
{showPermissionsDialog ? <PermissionsDialog
socket={props.socket}
changeProject={props.changeProject}
onClose={() => setShowPermissionsDialog(false)}
/> : null}
{importDialog ? <ImportProjectDialog
projects={props.projects}
themeType={props.themeType}
Expand Down
31 changes: 30 additions & 1 deletion src/src/Utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import type { CSSProperties } from '@mui/styles';
import { store } from '@/Store';
import {
GroupWidget, Widget, Project, SingleWidget, SingleWidgetId, GroupWidgetId, AnyWidgetId,
GroupWidget, Widget, Project, SingleWidget, SingleWidgetId, GroupWidgetId, AnyWidgetId, Permissions,
} from '@/types';

/** Default OID if no selected */
Expand Down Expand Up @@ -201,3 +201,32 @@ export function unsyncMultipleWidgets(project: Project): Project {

return project;
}

interface CheckAccessOptions {
/** The project the user wants to access */
project: Project;
/** The active user */
user: string;
/** True if running in edit mode */
editMode: boolean;
}

/** Default permissions if no given, user has full access */
export const DEFAULT_PERMISSIONS: Permissions = { read: true, write: true };

/**
* Check if the user has access to the project in given mode
*
* @param options project, user and mode information
*/
export function hasProjectAccess(options: CheckAccessOptions): boolean {
const { project, user, editMode } = options;

const permissions = project.___settings.permissions?.[user] ?? DEFAULT_PERMISSIONS;

if (editMode && permissions.write) {
return true;
}

return !editMode && permissions.read;
}
Loading

0 comments on commit d9c42fb

Please sign in to comment.