From e3ea1da4c20cfd87cf034684776682a9e4bdef34 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 22 Apr 2025 01:03:41 +0100 Subject: [PATCH 1/3] Add client side route protection --- client/src/App.vue | 19 ++--- client/src/router/index.js | 115 ++++++++++++++++++++++++++ client/src/store/modules/user/user.js | 8 +- server/controllers/api/auth.py | 5 +- 4 files changed, 128 insertions(+), 19 deletions(-) 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..0a4d0c12 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,101 @@ 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') { + 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/server/controllers/api/auth.py b/server/controllers/api/auth.py index a6747a81..76d59284 100644 --- a/server/controllers/api/auth.py +++ b/server/controllers/api/auth.py @@ -171,9 +171,8 @@ def get(self): 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 {}) From 5c684b5d06bdd6d6fbbf8f6f8d8d19b8ac251004 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 22 Apr 2025 01:12:16 +0100 Subject: [PATCH 2/3] Fix new session start not going to new page --- client/src/router/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/router/index.js b/client/src/router/index.js index 0a4d0c12..fc4418fb 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -208,6 +208,7 @@ router.beforeEach(async (to, from, 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; From 41bf546dd7f272ca6b09ffffebbb040efbe91fcc Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 22 Apr 2025 01:13:37 +0100 Subject: [PATCH 3/3] Remove redundant live page redirect logic --- client/src/views/show/ShowLiveView.vue | 127 ++++++++++++------------- 1 file changed, 61 insertions(+), 66 deletions(-) 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);