diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index 960e3ff..790d10a 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -188,7 +188,9 @@ function checkCrudPermissions(name) { } // Dynamically determine the permission name (e.g., 'READ_USERS') - const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`; + const permissionName = + req.permissionNameOverride || + `${METHOD_MAP[req.method]}_${name.toUpperCase()}`; // Call the checkPermissions middleware with the determined permission return checkPermissions(permissionName)(req, res, next); }; diff --git a/backend/src/routes/global_transition_defaults.js b/backend/src/routes/global_transition_defaults.js index 8579216..1f76a19 100644 --- a/backend/src/routes/global_transition_defaults.js +++ b/backend/src/routes/global_transition_defaults.js @@ -25,9 +25,17 @@ const allowPublicRead = (req, _res, next) => { return next(); }; +const authenticateWrites = (req, res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + return next(); + } + return jwtAuth(req, res, next); +}; + // Apply public read first, then CRUD permission checks router.use(allowPublicRead); -router.use(checkCrudPermissions('global_transition_defaults')); +router.use(authenticateWrites); +router.use(checkCrudPermissions('page_elements')); /** * @swagger @@ -120,7 +128,6 @@ router.get( */ router.put( '/:id', - jwtAuth, wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); await Global_transition_defaultsService.update({ diff --git a/backend/src/routes/global_ui_control_defaults.js b/backend/src/routes/global_ui_control_defaults.js index 6c056d0..10fbf46 100644 --- a/backend/src/routes/global_ui_control_defaults.js +++ b/backend/src/routes/global_ui_control_defaults.js @@ -15,8 +15,16 @@ const allowPublicRead = (req, _res, next) => { return next(); }; +const authenticateWrites = (req, res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + return next(); + } + return jwtAuth(req, res, next); +}; + router.use(allowPublicRead); -router.use(checkCrudPermissions('global_ui_control_defaults')); +router.use(authenticateWrites); +router.use(checkCrudPermissions('page_elements')); router.get( '/', @@ -42,7 +50,6 @@ router.get( router.put( '/:id', - jwtAuth, wrapAsync(async (req, res) => { await Global_ui_control_defaultsService.update({ id: req.params.id, diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index 4e2aab1..320c4aa 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -26,6 +26,23 @@ const allowAuthenticatedRead = (req, _res, next) => { return next(); }; +const authenticateWrites = (req, res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + return next(); + } + return jwtAuth(req, res, next); +}; + +const useUpdatePermissionForEnvironmentReset = (req, _res, next) => { + if ( + req.method === 'DELETE' && + /^\/project\/[^/]+\/env\/[^/]+\/?$/.test(req.path) + ) { + req.permissionNameOverride = 'UPDATE_PAGE_ELEMENTS'; + } + return next(); +}; + /** * Middleware: Production GET is public, everything else requires JWT. * Determines public access from URL path, not headers. @@ -105,7 +122,9 @@ const requireProductionOrAuth = async (req, res, next) => { // Mark reads as public first, then apply CRUD permission checks router.use(allowAuthenticatedRead); -router.use(checkCrudPermissions('project_transition_settings')); +router.use(authenticateWrites); +router.use(useUpdatePermissionForEnvironmentReset); +router.use(checkCrudPermissions('page_elements')); /** * @swagger @@ -214,7 +233,6 @@ router.get( */ router.put( '/project/:projectId/env/:environment', - jwtAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; @@ -264,7 +282,6 @@ router.put( */ router.delete( '/project/:projectId/env/:environment', - jwtAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; @@ -339,7 +356,6 @@ router.get( */ router.post( '/', - jwtAuth, wrapAsync(async (req, res) => { const payload = await Project_transition_settingsService.create({ data: req.body.data, @@ -414,7 +430,6 @@ router.get( */ router.put( '/:id', - jwtAuth, wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); await Project_transition_settingsService.update({ @@ -448,7 +463,6 @@ router.put( */ router.delete( '/:id', - jwtAuth, wrapAsync(async (req, res) => { await Project_transition_settingsService.remove({ id: req.params.id, diff --git a/backend/src/routes/project_ui_control_settings.js b/backend/src/routes/project_ui_control_settings.js index b3365db..56374ad 100644 --- a/backend/src/routes/project_ui_control_settings.js +++ b/backend/src/routes/project_ui_control_settings.js @@ -17,6 +17,23 @@ const allowAuthenticatedRead = (req, _res, next) => { return next(); }; +const authenticateWrites = (req, res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + return next(); + } + return jwtAuth(req, res, next); +}; + +const useUpdatePermissionForEnvironmentReset = (req, _res, next) => { + if ( + req.method === 'DELETE' && + /^\/project\/[^/]+\/env\/[^/]+\/?$/.test(req.path) + ) { + req.permissionNameOverride = 'UPDATE_PAGE_ELEMENTS'; + } + return next(); +}; + const getRuntimeProjectSlug = async (req) => { if (req.runtimeContext?.headerProjectSlug) { return req.runtimeContext.headerProjectSlug; @@ -87,7 +104,9 @@ const requireProductionOrAuth = async (req, res, next) => { }; router.use(allowAuthenticatedRead); -router.use(checkCrudPermissions('project_ui_control_settings')); +router.use(authenticateWrites); +router.use(useUpdatePermissionForEnvironmentReset); +router.use(checkCrudPermissions('page_elements')); router.get( '/project/:projectId/env/:environment', @@ -116,7 +135,6 @@ router.get( router.put( '/project/:projectId/env/:environment', - jwtAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; @@ -141,7 +159,6 @@ router.put( router.delete( '/project/:projectId/env/:environment', - jwtAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; diff --git a/backend/tests/check-permissions.test.js b/backend/tests/check-permissions.test.js new file mode 100644 index 0000000..cd41111 --- /dev/null +++ b/backend/tests/check-permissions.test.js @@ -0,0 +1,77 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const RolesDBApi = require('../src/db/api/roles'); +const AccessPolicy = require('../src/services/access-policy'); + +const originalFindBy = RolesDBApi.findBy; +RolesDBApi.findBy = async () => ({ + id: 'public-role', + name: 'Public', + permissions: [], +}); + +const { checkCrudPermissions } = require('../src/middlewares/check-permissions'); + +test.after(() => { + RolesDBApi.findBy = originalFindBy; +}); + +test('checkCrudPermissions honors explicit permission override', async () => { + const originalHasPermission = AccessPolicy.hasPermission; + const seenPermissions = []; + + AccessPolicy.hasPermission = async (_user, permission) => { + seenPermissions.push(permission); + return permission === 'UPDATE_PAGE_ELEMENTS'; + }; + + try { + const req = { + method: 'DELETE', + path: '/project/project-id/env/dev', + currentUser: { id: 'user-1' }, + permissionNameOverride: 'UPDATE_PAGE_ELEMENTS', + }; + + await new Promise((resolve, reject) => { + checkCrudPermissions('page_elements')(req, {}, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + + assert.deepEqual(seenPermissions, ['UPDATE_PAGE_ELEMENTS']); + } finally { + AccessPolicy.hasPermission = originalHasPermission; + } +}); + +test('checkCrudPermissions keeps default method-derived permission without override', async () => { + const originalHasPermission = AccessPolicy.hasPermission; + const seenPermissions = []; + + AccessPolicy.hasPermission = async (_user, permission) => { + seenPermissions.push(permission); + return permission === 'DELETE_PAGE_ELEMENTS'; + }; + + try { + const req = { + method: 'DELETE', + path: '/project/project-id/env/dev', + currentUser: { id: 'user-1' }, + }; + + await new Promise((resolve, reject) => { + checkCrudPermissions('page_elements')(req, {}, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + + assert.deepEqual(seenPermissions, ['DELETE_PAGE_ELEMENTS']); + } finally { + AccessPolicy.hasPermission = originalHasPermission; + } +}); diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index cf6074c..ad799ee 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -38,12 +38,6 @@ import { selectByProjectAndEnv, selectIsLoading as selectTransitionSettingsLoading, } from '../stores/project_transition_settings/projectTransitionSettingsSlice'; -import { - fetchByProjectAndEnv as fetchUiControlsByProjectAndEnv, - upsertByProjectAndEnv as upsertUiControlsByProjectAndEnv, - deleteByProjectAndEnv as deleteUiControlsByProjectAndEnv, - selectByProjectAndEnv as selectUiControlsByProjectAndEnv, -} from '../stores/project_ui_control_settings/projectUiControlSettingsSlice'; import type { ProjectTransitionSettings, TransitionType, @@ -142,12 +136,6 @@ const TourFlowManager = () => { ? selectTransitionSettingsLoading(state, selectedProjectId, 'dev') : false, ); - const projectUiControlsSettingsEntity = useAppSelector((state) => - selectedProjectId - ? selectUiControlsByProjectAndEnv(state, selectedProjectId, 'dev') - : undefined, - ); - // Project transition settings state const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] = useState(false); @@ -166,9 +154,6 @@ const TourFlowManager = () => { useState(false); const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false); const [isUiControlsExpanded, setIsUiControlsExpanded] = useState(false); - const [uiControlsJson, setUiControlsJson] = useState(''); - const [isSavingUiControls, setIsSavingUiControls] = useState(false); - const [uiControlsSaveSuccess, setUiControlsSaveSuccess] = useState(false); const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES'); const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS'); @@ -269,12 +254,6 @@ const TourFlowManager = () => { environment: 'dev', }), ); - dispatch( - fetchUiControlsByProjectAndEnv({ - projectId: selectedProjectId, - environment: 'dev', - }), - ); }, [selectedProjectId, dispatch]); // Sync local form state when store data changes @@ -285,16 +264,6 @@ const TourFlowManager = () => { setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? ''); }, [projectTransitionSettings]); - useEffect(() => { - setUiControlsJson( - JSON.stringify( - projectUiControlsSettingsEntity?.settings_json || {}, - null, - 2, - ), - ); - }, [projectUiControlsSettingsEntity]); - useEffect(() => { if (!selectedProjectId) return; if (routeProjectId && selectedProjectId === routeProjectId) return; @@ -672,41 +641,6 @@ const TourFlowManager = () => { } }; - const handleSaveUiControlsSettings = async () => { - if (!selectedProjectId) return; - - setIsSavingUiControls(true); - setUiControlsSaveSuccess(false); - try { - const parsed = JSON.parse(uiControlsJson || '{}'); - - if (!parsed || Object.keys(parsed).length === 0) { - await dispatch( - deleteUiControlsByProjectAndEnv({ - projectId: selectedProjectId, - environment: 'dev', - }), - ).unwrap(); - } else { - await dispatch( - upsertUiControlsByProjectAndEnv({ - projectId: selectedProjectId, - environment: 'dev', - data: { settings_json: parsed }, - }), - ).unwrap(); - } - - setUiControlsSaveSuccess(true); - setTimeout(() => setUiControlsSaveSuccess(false), 2000); - } catch (error) { - logger.error('Failed to save project UI controls settings:', error); - toast.error('Failed to save UI controls settings'); - } finally { - setIsSavingUiControls(false); - } - }; - const handleDelete = async ( event: React.MouseEvent, id: string, @@ -962,30 +896,14 @@ const TourFlowManager = () => {
Override fixed-size fullscreen, sound, and offline button defaults for this project in dev. Positions are - canvas-relative percentages; dimensions are CSS pixels. Empty - JSON reverts to global defaults. + canvas-relative percentages.
-