diff --git a/client/package-lock.json b/client/package-lock.json index 950720bd..16dd41f8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", diff --git a/client/package.json b/client/package.json index 1b1d3af1..9736b9dc 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.9.0", + "version": "0.10.0", "private": true, "scripts": { "build": "vite build", diff --git a/client/src/App.vue b/client/src/App.vue index 317bc10d..e01978e5 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -155,9 +155,13 @@ export default { startingSession: false, }; }, + async created() { + await this.GET_SETTINGS(); + await this.awaitWSConnect(); + }, methods: { ...mapActions(['GET_SHOW_SESSION_DATA', 'GET_CURRENT_USER', 'USER_LOGOUT', 'GET_RBAC_ROLES', - 'GET_CURRENT_RBAC']), + 'GET_CURRENT_RBAC', 'GET_SETTINGS']), async awaitWSConnect() { if (this.WEBSOCKET_HEALTHY) { clearTimeout(this.loadTimer); @@ -266,19 +270,6 @@ export default { ...mapGetters(['WEBSOCKET_HEALTHY', 'CURRENT_SHOW_SESSION', 'SETTINGS', 'CURRENT_USER', 'RBAC_ROLES', 'CURRENT_USER_RBAC', 'INTERNAL_UUID']), }, - async created() { - this.$router.beforeEach(async (to, from, next) => { - if (!this.SETTINGS.has_admin_user) { - this.$toast.error('Please create an admin user before continuing'); - next(false); - } else if (to.fullPath === '/config' && (this.CURRENT_USER == null || !this.CURRENT_USER.is_admin)) { - next(false); - } else { - next(); - } - }); - await this.awaitWSConnect(); - }, }; diff --git a/client/src/router/index.js b/client/src/router/index.js index 73e94d12..fc4418fb 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -9,70 +9,84 @@ const routes = [ path: '/', name: 'home', component: HomeView, + meta: { requiresAuth: false }, }, { path: '/about', name: 'about', component: () => import('../views/AboutView.vue'), + meta: { requiresAuth: false }, }, { path: '/config', name: 'config', component: () => import('../views/config/ConfigView.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, }, { path: '/show-config', component: () => import('../views/show/ShowConfigView.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, children: [ { name: 'show-config', path: '', component: () => import('../views/show/config/ConfigShow.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-cast', path: 'cast', component: () => import('../views/show/config/ConfigCast.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-characters', path: 'characters', component: () => import('../views/show/config/ConfigCharacters.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-character-groups', path: 'character-groups', component: () => import('../views/show/config/ConfigCharacterGroups.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-acts', path: 'acts', component: () => import('../views/show/config/ConfigActs.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-scenes', path: 'scenes', component: () => import('../views/show/config/ConfigScenes.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-cues', path: 'cues', component: () => import('../views/show/config/ConfigCues.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-mics', path: 'mics', component: () => import('../views/show/config/ConfigMics.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-script', path: 'script', component: () => import('../views/show/config/ConfigScript.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-sessions', path: 'sessions', component: () => import('../views/show/config/ConfigSessions.vue'), + meta: { requiresAuth: true, requiresShowAccess: true }, }, ], }, @@ -80,21 +94,25 @@ const routes = [ path: '/live', name: 'live', component: () => import('../views/show/ShowLiveView.vue'), + meta: { requiresAuth: false }, }, { path: '/login', name: 'login', component: () => import('../views/user/LoginView.vue'), + meta: { requiresAuth: false }, }, { path: '/me', name: 'user_settings', component: () => import('../views/user/Settings.vue'), + meta: { requiresAuth: true }, }, { path: '*', name: '404', component: () => import('../views/404View.vue'), + meta: { requiresAuth: false }, }, ]; @@ -104,4 +122,102 @@ const router = new VueRouter({ routes, }); +router.beforeEach(async (to, from, next) => { + // Silly code needed for... reasons... this seems to correctly set the correct state + // on the router and do the fetch needed for anything else. Shrug... + let requiresSettingsFetch = false; + if (router.app.$store === undefined) { + await router.app.$nextTick(); + requiresSettingsFetch = true; + } + if (requiresSettingsFetch) { + await router.app.$store.dispatch('GET_SETTINGS'); + await router.app.$store.dispatch('GET_CURRENT_USER'); + if (router.app.$store.getters.CURRENT_USER) { + await router.app.$store.dispatch('GET_CURRENT_RBAC'); + } + } + + const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); + const requiresAdmin = to.matched.some((record) => record.meta.requiresAdmin); + const requiresShowAccess = to.matched.some((record) => record.meta.requiresShowAccess); + + // Check if the system has admin user set up + if (router.app.$store.getters.SETTINGS && !router.app.$store.getters.SETTINGS.has_admin_user) { + if (to.path !== '/') { + Vue.$toast.error('Please create an admin user before continuing'); + return next('/'); + } + return next(); + } + + const currentUser = router.app.$store.getters.CURRENT_USER; + const isAuthenticated = currentUser !== null; + const isAdmin = currentUser?.is_admin === true; + + // Helper function to check show access + const hasShowAccess = () => { + if (!router.app.$store.getters.CURRENT_SHOW) return false; + + // Admin always has access + if (isAdmin) return true; + + // Check RBAC permissions for show edit/execute + const rbacRoles = router.app.$store.getters.RBAC_ROLES; + const userRbac = router.app.$store.getters.CURRENT_USER_RBAC; + + if (!rbacRoles.length || !userRbac || !Object.keys(userRbac).includes('shows')) { + return false; + } + + const writeMask = rbacRoles.find((x) => x.key === 'WRITE')?.value || 0; + const executeMask = rbacRoles.find((x) => x.key === 'EXECUTE')?.value || 0; + + // Bitwise check if user has WRITE or EXECUTE permission for shows + return userRbac.shows && userRbac.shows[0] + // eslint-disable-next-line no-bitwise + && ((userRbac.shows[0][1] & (writeMask | executeMask)) !== 0); + }; + + // Check authentication requirements + if (requiresAuth && !isAuthenticated) { + Vue.$toast.error('Please log in to access this page'); + return next('/login'); + } + + // Check admin requirements + if (requiresAdmin && !isAdmin) { + Vue.$toast.error('Admin access required'); + return next('/'); + } + + // Check show access requirements + if (requiresShowAccess) { + // First check if there's a current show + if (!router.app.$store.getters.CURRENT_SHOW) { + Vue.$toast.error('No show is currently selected'); + return next('/'); + } + + // Then check permissions + if (!hasShowAccess()) { + Vue.$toast.error('You do not have permission to access show configuration'); + return next('/'); + } + } + + // Handle special case for live route + if (to.path === '/live') { + await router.app.$store.dispatch('GET_SHOW_SESSION_DATA'); + const showSession = router.app.$store.getters.CURRENT_SHOW_SESSION; + const websocketHealthy = router.app.$store.getters.WEBSOCKET_HEALTHY; + + if (!showSession || !websocketHealthy) { + Vue.$toast.error('No active show session or connection issue'); + return next('/'); + } + } + return next(); +}); + export default router; diff --git a/client/src/store/modules/user/user.js b/client/src/store/modules/user/user.js index 9d69bf28..f5665e30 100644 --- a/client/src/store/modules/user/user.js +++ b/client/src/store/modules/user/user.js @@ -3,6 +3,7 @@ import log from 'loglevel'; import { makeURL } from '@/js/utils'; import router from '@/router'; +import { isEmpty } from 'lodash'; import settings from './settings'; export default { @@ -94,7 +95,9 @@ export default { if (response.ok) { await context.commit('SET_CURRENT_USER', null); Vue.$toast.success('Successfully logged out!'); - router.push('/'); + if (router.currentRoute.path !== '/') { + router.push('/'); + } } else { log.error('Unable to log out'); Vue.$toast.error('Unable to log out!'); @@ -104,7 +107,8 @@ export default { const response = await fetch(`${makeURL('/api/v1/auth')}`); if (response.ok) { const user = await response.json(); - await context.commit('SET_CURRENT_USER', user); + const userJson = isEmpty(user) ? null : user; + await context.commit('SET_CURRENT_USER', userJson); } else { log.error('Unable to get current user'); } diff --git a/client/src/views/show/ShowLiveView.vue b/client/src/views/show/ShowLiveView.vue index 7264b76f..c569992e 100644 --- a/client/src/views/show/ShowLiveView.vue +++ b/client/src/views/show/ShowLiveView.vue @@ -140,81 +140,63 @@ export default { async mounted() { await this.GET_SHOW_SESSION_DATA(); this.loadedSessionData = true; - if (this.CURRENT_SHOW_SESSION == null) { - this.$toast.warning('No live session started!'); - await this.$router.replace('/'); - } else { - // Get the current user - await this.GET_CURRENT_USER(); + // Get the current user + await this.GET_CURRENT_USER(); - // User related stuff - if (this.CURRENT_USER != null) { - await this.GET_STAGE_DIRECTION_STYLE_OVERRIDES(); - } + // User related stuff + if (this.CURRENT_USER != null) { + await this.GET_STAGE_DIRECTION_STYLE_OVERRIDES(); + } - await this.GET_ACT_LIST(); - await this.GET_SCENE_LIST(); - await this.GET_CHARACTER_LIST(); - await this.GET_CHARACTER_GROUP_LIST(); - await this.GET_CUE_TYPES(); - await this.LOAD_CUES(); - await this.GET_CUTS(); - await this.GET_STAGE_DIRECTION_STYLES(); - await this.getMaxScriptPage(); + await this.GET_ACT_LIST(); + await this.GET_SCENE_LIST(); + await this.GET_CHARACTER_LIST(); + await this.GET_CHARACTER_GROUP_LIST(); + await this.GET_CUE_TYPES(); + await this.LOAD_CUES(); + await this.GET_CUTS(); + await this.GET_STAGE_DIRECTION_STYLES(); + await this.getMaxScriptPage(); - this.updateElapsedTime(); - this.computeContentSize(); + this.updateElapsedTime(); + this.computeContentSize(); - this.startTime = this.createDateAsUTC(new Date(this.CURRENT_SHOW_SESSION.start_date_time.replace(' ', 'T'))); - this.elapsedTimer = setInterval(this.updateElapsedTime, 1000); - window.addEventListener('resize', debounce(this.computeContentSize, 100)); + this.startTime = this.createDateAsUTC(new Date(this.CURRENT_SHOW_SESSION.start_date_time.replace(' ', 'T'))); + this.elapsedTimer = setInterval(this.updateElapsedTime, 1000); + window.addEventListener('resize', debounce(this.computeContentSize, 100)); - if (this.isScriptFollowing || this.isScriptLeader) { - if (this.CURRENT_SHOW_SESSION.latest_line_ref != null) { - const loadCurrentPage = parseInt(this.CURRENT_SHOW_SESSION.latest_line_ref.split('_')[1], 10); + if (this.isScriptFollowing || this.isScriptLeader) { + if (this.CURRENT_SHOW_SESSION.latest_line_ref != null) { + const loadCurrentPage = parseInt(this.CURRENT_SHOW_SESSION.latest_line_ref.split('_')[1], 10); - if (this.SETTINGS.enable_lazy_loading === false) { - this.currentMinLoadedPage = 1; - for (let loadIndex = 0; loadIndex < this.currentMaxPage; loadIndex++) { - // eslint-disable-next-line no-await-in-loop - await this.loadNextPage(); - } - } else { - this.currentMinLoadedPage = Math.max(0, loadCurrentPage - this.pageBatchSize - 1); - this.currentLoadedPage = this.currentMinLoadedPage; - for (let loadIndex = this.currentMinLoadedPage; - loadIndex < loadCurrentPage + this.pageBatchSize; loadIndex++) { - // eslint-disable-next-line no-await-in-loop - await this.loadNextPage(); - } - this.currentMinLoadedPage += 1; + if (this.SETTINGS.enable_lazy_loading === false) { + this.currentMinLoadedPage = 1; + for (let loadIndex = 0; loadIndex < this.currentMaxPage; loadIndex++) { + // eslint-disable-next-line no-await-in-loop + await this.loadNextPage(); } - - this.currentFirstPage = loadCurrentPage; - this.currentLastPage = loadCurrentPage; - await this.$nextTick(); - this.initialLoad = true; - await this.$nextTick(); - this.computeScriptBoundaries(); - document.getElementById(this.CURRENT_SHOW_SESSION.latest_line_ref).scrollIntoView({ - behavior: 'instant', - }); - await this.$nextTick(); - this.computeScriptBoundaries(); } else { - this.currentMinLoadedPage = 1; - if (this.SETTINGS.enable_lazy_loading === false) { - for (let loadIndex = 0; loadIndex < this.currentMaxPage; loadIndex++) { - // eslint-disable-next-line no-await-in-loop - await this.loadNextPage(); - } - } else { + this.currentMinLoadedPage = Math.max(0, loadCurrentPage - this.pageBatchSize - 1); + this.currentLoadedPage = this.currentMinLoadedPage; + for (let loadIndex = this.currentMinLoadedPage; + loadIndex < loadCurrentPage + this.pageBatchSize; loadIndex++) { + // eslint-disable-next-line no-await-in-loop await this.loadNextPage(); } - this.initialLoad = true; - await this.$nextTick(); - this.computeScriptBoundaries(); + this.currentMinLoadedPage += 1; } + + this.currentFirstPage = loadCurrentPage; + this.currentLastPage = loadCurrentPage; + await this.$nextTick(); + this.initialLoad = true; + await this.$nextTick(); + this.computeScriptBoundaries(); + document.getElementById(this.CURRENT_SHOW_SESSION.latest_line_ref).scrollIntoView({ + behavior: 'instant', + }); + await this.$nextTick(); + this.computeScriptBoundaries(); } else { this.currentMinLoadedPage = 1; if (this.SETTINGS.enable_lazy_loading === false) { @@ -229,9 +211,22 @@ export default { await this.$nextTick(); this.computeScriptBoundaries(); } - - this.fullLoad = true; + } else { + this.currentMinLoadedPage = 1; + if (this.SETTINGS.enable_lazy_loading === false) { + for (let loadIndex = 0; loadIndex < this.currentMaxPage; loadIndex++) { + // eslint-disable-next-line no-await-in-loop + await this.loadNextPage(); + } + } else { + await this.loadNextPage(); + } + this.initialLoad = true; + await this.$nextTick(); + this.computeScriptBoundaries(); } + + this.fullLoad = true; }, destroyed() { clearInterval(this.elapsedTimer); diff --git a/server/alembic_config/versions/be353176c064_detach_users_from_shows.py b/server/alembic_config/versions/be353176c064_detach_users_from_shows.py new file mode 100644 index 00000000..3dd8c168 --- /dev/null +++ b/server/alembic_config/versions/be353176c064_detach_users_from_shows.py @@ -0,0 +1,41 @@ +"""Detach users from shows + +Revision ID: be353176c064 +Revises: a39ac9e9f085 +Create Date: 2025-04-20 22:27:07.342092 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "be353176c064" +down_revision: Union[str, None] = "a39ac9e9f085" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_index("ix_user_show_id") + try: + batch_op.drop_constraint(None, type_="foreignkey") + except IndexError: + pass + batch_op.drop_column("show_id") + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column(sa.Column("show_id", sa.INTEGER(), nullable=True)) + batch_op.create_foreign_key(None, "shows", ["show_id"], ["id"]) + batch_op.create_index("ix_user_show_id", ["show_id"], unique=False) + + # ### end Alembic commands ### diff --git a/server/controllers/api/auth.py b/server/controllers/api/auth.py index 65da1ced..76d59284 100644 --- a/server/controllers/api/auth.py +++ b/server/controllers/api/auth.py @@ -5,7 +5,6 @@ from tornado.ioloop import IOLoop from models.session import Session -from models.show import Show from models.user import User from schemas.schemas import UserSchema from utils.web.base_controller import BaseAPIController @@ -37,27 +36,9 @@ async def post(self): ) return - show_id = data.get("show_id", None) is_admin = data.get("is_admin", False) - if not show_id and not is_admin: - self.set_status(400) - await self.finish({"message": "Non admin user requires a show allocation"}) - return - - if is_admin and show_id: - self.set_status(400) - await self.finish({"message": "Admin user cannot have a show allocation"}) - return - with self.make_session() as session: - if show_id: - show = session.query(Show).get(show_id) - if not show: - self.set_status(400) - await self.finish({"message": "Show not found"}) - return - conflict_user = ( session.query(User).filter(User.username == username).first() ) @@ -75,7 +56,6 @@ async def post(self): User( username=username, password=hashed_password, - show_id=show_id, is_admin=is_admin, ) ) @@ -113,21 +93,6 @@ async def post(self): await self.finish({"message": "Invalid username/password"}) return - if not user.is_admin: - if not self.get_current_show(): - self.set_status(403) - await self.finish( - { - "message": "Non admin user cannot log in without a loaded show" - } - ) - return - - if user.show_id != self.get_current_show()["id"]: - self.set_status(403) - await self.finish({"message": "Loaded show does not match user"}) - return - password_equal = await IOLoop.current().run_in_executor( None, bcrypt.checkpw, @@ -201,20 +166,13 @@ class UsersHandler(BaseAPIController): def get(self): user_schema = UserSchema() with self.make_session() as session: - users = ( - session.query(User) - .filter( - (User.show_id == self.get_current_show()["id"]) | (User.is_admin) - ) - .all() - ) + users = session.query(User).all() self.set_status(200) self.finish({"users": [user_schema.dump(u) for u in users]}) -@ApiRoute("/auth", ApiVersion.V1) +@ApiRoute("auth", ApiVersion.V1) class AuthHandler(BaseAPIController): - @web.authenticated def get(self): self.set_status(200) - self.finish(self.current_user) + self.finish(self.current_user if self.current_user else {}) diff --git a/server/models/show.py b/server/models/show.py index 8c4b1682..34ee4b6d 100644 --- a/server/models/show.py +++ b/server/models/show.py @@ -39,7 +39,6 @@ class Show(db.Model): ) scene_list = relationship("Scene", cascade="all, delete-orphan") cue_type_list = relationship("CueType", cascade="all, delete-orphan") - users = relationship("User", uselist=True, cascade="all, delete-orphan") class Cast(db.Model): diff --git a/server/models/user.py b/server/models/user.py index 8729be5d..cd1eed7a 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -15,7 +15,6 @@ class User(db.Model): id = Column(Integer(), primary_key=True, autoincrement=True) username = Column(String(), index=True) password = Column(String()) - show_id = Column(Integer(), ForeignKey("shows.id"), index=True) is_admin = Column(Boolean()) last_login = Column(DateTime()) diff --git a/server/rbac/rbac_db.py b/server/rbac/rbac_db.py index 30fd0d2d..36ee566b 100644 --- a/server/rbac/rbac_db.py +++ b/server/rbac/rbac_db.py @@ -9,6 +9,7 @@ from digi_server.logger import get_logger from models.models import db from models.show import Show +from models.user import User from rbac.exceptions import RBACException from rbac.role import Role from utils import tree @@ -88,7 +89,7 @@ def add_mapping(self, actor: type, resource: type) -> None: actor_inspect = inspect(actor) resource_inspect = inspect(resource) - if not self._has_link_to_show(actor_inspect.mapped_table): + if actor is not User and not self._has_link_to_show(actor_inspect.mapped_table): raise RBACException( "actor class does not have a reference back to Show table" ) diff --git a/server/test/test_auth_api.py b/server/test/test_auth_api.py index 18a4ba85..429eb107 100644 --- a/server/test/test_auth_api.py +++ b/server/test/test_auth_api.py @@ -50,67 +50,6 @@ def test_create_admin(self): self.assertTrue("message" in response_body) self.assertEqual("Successfully created user", response_body["message"]) - def test_invalid_admin(self): - response = self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - { - "username": "foobar", - "password": "password", - "is_admin": True, - "show_id": 1, - } - ), - ) - response_body = escape.json_decode(response.body) - - self.assertEqual(400, response.code) - self.assertTrue("message" in response_body) - self.assertEqual( - "Admin user cannot have a show allocation", response_body["message"] - ) - - def test_invalid_user(self): - response = self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - { - "username": "foobar", - "password": "password", - "is_admin": False, - "show_id": None, - } - ), - ) - response_body = escape.json_decode(response.body) - - self.assertEqual(400, response.code) - self.assertTrue("message" in response_body) - self.assertEqual( - "Non admin user requires a show allocation", response_body["message"] - ) - - def test_invalid_show(self): - response = self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - { - "username": "foobar", - "password": "password", - "is_admin": False, - "show_id": 1, - } - ), - ) - response_body = escape.json_decode(response.body) - - self.assertEqual(400, response.code) - self.assertTrue("message" in response_body) - self.assertEqual("Show not found", response_body["message"]) - def test_login_success(self): self.fetch( "/api/v1/auth/create",