From d9c42fbf7f6aec88f48a7abf992b36badd4e51a4 Mon Sep 17 00:00:00 2001 From: Max Hauser Date: Fri, 12 Jan 2024 17:49:58 +0100 Subject: [PATCH] Permission system (#304) * 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 --- README.md | 23 +++ src/src/Runtime.jsx | 25 ++- src/src/Store.tsx | 7 +- .../ProjectsManager/PermissionsDialog.tsx | 155 ++++++++++++++++++ src/src/Toolbar/ProjectsManager/index.jsx | 20 +++ src/src/Utils/utils.tsx | 31 +++- src/src/i18n/de.json | 32 ++-- src/src/i18n/en.json | 32 ++-- src/src/i18n/es.json | 32 ++-- src/src/i18n/fr.json | 32 ++-- src/src/i18n/it.json | 32 ++-- src/src/i18n/nl.json | 32 ++-- src/src/i18n/pl.json | 32 ++-- src/src/i18n/pt.json | 32 ++-- src/src/i18n/ru.json | 32 ++-- src/src/i18n/uk.json | 32 ++-- src/src/i18n/zh-cn.json | 32 ++-- src/src/types.d.ts | 14 ++ 18 files changed, 480 insertions(+), 147 deletions(-) create mode 100644 src/src/Toolbar/ProjectsManager/PermissionsDialog.tsx diff --git a/README.md b/README.md index db711d971..209638f72 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 diff --git a/src/src/Runtime.jsx b/src/src/Runtime.jsx index 001f9902e..9f96731e1 100644 --- a/src/src/Runtime.jsx +++ b/src/src/Runtime.jsx @@ -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', @@ -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); @@ -889,6 +894,8 @@ class Runtime extends GenericApp { } showSmallProjectsDialog() { + const { visProject, activeUser } = store.getState(); + return : null} {this.state.projects.map(project => - window.location.href = `?${project}`}> + window.location.href = `?${project}`} disabled={!hasProjectAccess({ editMode: this.state.editMode, project: visProject, user: activeUser })}> @@ -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 ('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('activeUser/update'); export const recalculateFields = createAction('attributes/recalculate'); const initialState = { visProject: {} as Project, recalculateFields: false, + activeUser: '', }; const reducer = createReducer( @@ -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; }); @@ -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, diff --git a/src/src/Toolbar/ProjectsManager/PermissionsDialog.tsx b/src/src/Toolbar/ProjectsManager/PermissionsDialog.tsx new file mode 100644 index 000000000..92869d3a3 --- /dev/null +++ b/src/src/Toolbar/ProjectsManager/PermissionsDialog.tsx @@ -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; +} + +export default class PermissionsDialog extends React.Component { + /** 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 { + const userView: Record = await this.props.socket.getObjectViewSystem('user', 'system.user.', 'system.user.\u9999'); + const { visProject } = store.getState(); + const projectPermissions = new Map(); + + 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
+ +
+

+ {I18n.t('Only the admin user can change permissions')} +
+ {I18n.t('Read = Runtime access')} +
+ {I18n.t('Write = Edit mode access')} +

+
+
; + } + + /** + * Render the actual component + */ + render(): React.JSX.Element { + const { activeUser } = store.getState(); + + return this.props.onClose()} + actionNoClose + action={() => this.onSave()} + actionTitle="Save" + ActionIcon={SaveIcon} + actionDisabled={false} + closeDisabled={false} + > + {this.renderInfoDialog()} + {this.state.users.map(user =>
+
{`${user}:`}
+
+ { + 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')} + { + 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')} +
+
)} +
; + } +} diff --git a/src/src/Toolbar/ProjectsManager/index.jsx b/src/src/Toolbar/ProjectsManager/index.jsx index 5010afd4c..02570e5ee 100644 --- a/src/src/Toolbar/ProjectsManager/index.jsx +++ b/src/src/Toolbar/ProjectsManager/index.jsx @@ -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'; @@ -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: { @@ -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); @@ -185,6 +188,18 @@ const ProjectsManage = props => { {projectName} + + {working === projectName ? : + { + setAnchorEl(event.currentTarget); + setShowPermissionsDialog(projectName); + }} + size="small" + > + + } + {working === projectName ? : { {...props} classes={{}} /> : null} + {showPermissionsDialog ? setShowPermissionsDialog(false)} + /> : null} {importDialog ? ; +export interface Permissions { + /** Accessible in Runtime */ + read: boolean; + /** Accessible in Editor */ + write: boolean; +} + +interface ProjectPermissions { + /** Which user has read or write access for the project */ + [user: string]: Permissions; +} + export interface ProjectSettings { darkReloadScreen: boolean; destroyViewsAfter: number; @@ -13,6 +25,8 @@ export interface ProjectSettings { reloadOnSleep: number; statesDebounceTime: number; scripts: unknown; + /** Which user has read or write access for the project */ + permissions?: ProjectPermissions; } interface SingleWidget {