diff --git a/backend/__tests__/routes/adminRolesGuards.test.js b/backend/__tests__/routes/adminRolesGuards.test.js new file mode 100644 index 00000000..00c71265 --- /dev/null +++ b/backend/__tests__/routes/adminRolesGuards.test.js @@ -0,0 +1,86 @@ +/** + * Role-editor self-amplification guard (migration 175 / adminRoles). + * + * `roles.manage` must be a DELEGATION primitive, not root escalation: a + * non-super_admin holder can only grant permissions their OWN role already + * holds, and can't edit their own role. super_admin bypasses. Pins + * userManagementService.createRole / updateRole (assertActorMayGrant). + */ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-roleguard-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'roleguard-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-roleguard-storage-')); + +const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb'); +const svc = require('../../src/services/userManagementService'); +const { clearPermissionCache } = require('../../src/middleware/permissions'); + +describe('role editor — self-amplification guard', () => { + let db; let cleanup; + let superId; let mgrRoleId; let mgrId; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId: superId } = await seedMinimal(db)); + await assignAdminRole(db, superId, 'super_admin'); + + // A non-super role that CAN manage roles but only holds a couple of perms. + const mgrRole = await svc.createRole( + { name: 'limited_mgr', permissions: ['roles.manage', 'events.view'] }, + superId, + ); + mgrRoleId = mgrRole.id; + const ins = await db('admin_users').insert({ + username: 'mgr', email: 'mgr@example.com', password_hash: 'x', + role_id: mgrRoleId, must_change_password: false, created_at: new Date(), + }).returning('id'); + mgrId = ins[0]?.id ?? ins[0]; + clearPermissionCache(); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('super_admin can grant any permission', async () => { + const r = await svc.createRole( + { name: 'power_role', permissions: ['settings.banking', 'users.delete'] }, + superId, + ); + expect(r.permissions).toEqual(expect.arrayContaining(['settings.banking', 'users.delete'])); + }); + + it('non-super cannot grant a permission its own role lacks', async () => { + await expect( + svc.createRole({ name: 'sneaky', permissions: ['events.view', 'settings.banking'] }, mgrId), + ).rejects.toThrow(/only grant permissions your own role/i); + }); + + it('non-super can create a role within its own permissions', async () => { + const r = await svc.createRole({ name: 'viewer_lite', permissions: ['events.view'] }, mgrId); + expect(r.permissions).toEqual(['events.view']); + }); + + it('non-super cannot edit its own role', async () => { + await expect( + svc.updateRole(mgrRoleId, { permissions: ['roles.manage', 'events.view'] }, mgrId), + ).rejects.toThrow(/cannot edit your own role/i); + }); + + it('non-super cannot escalate another role beyond its own permissions', async () => { + const adminRole = await db('roles').where({ name: 'admin' }).first(); + await expect( + svc.updateRole(adminRole.id, { permissions: ['settings.banking'] }, mgrId), + ).rejects.toThrow(/only grant permissions your own role/i); + }); + + it('the built-in team_photographer name is reserved', async () => { + await expect( + svc.createRole({ name: 'team_photographer', permissions: [] }, superId), + ).rejects.toThrow(/reserved/i); + }); +}); diff --git a/backend/__tests__/routes/adminSettingsProtectedKeys.test.js b/backend/__tests__/routes/adminSettingsProtectedKeys.test.js new file mode 100644 index 00000000..521734b9 --- /dev/null +++ b/backend/__tests__/routes/adminSettingsProtectedKeys.test.js @@ -0,0 +1,95 @@ +/** + * Protected-key boundary on the generic settings writers (migration 175). + * + * A role with settings.edit but NOT settings.domains (the "office manager" this + * PR enables) must be able to save the General tab — which re-posts + * general_site_url on every save — as long as the URL is UNCHANGED, and must be + * 403'd only when it actually tries to change a protected key. Regression pin for + * the change-detection fix (the presence-only check over-fired on every save). + */ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-setkeys-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'setkeys-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-setkeys-storage-')); + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const { + bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, +} = require('../integration/helpers/crmDb'); +const svc = require('../../src/services/userManagementService'); +const { clearPermissionCache } = require('../../src/middleware/permissions'); + +const STORED_URL = 'https://stored.example'; + +describe('settings protected-key boundary (/general)', () => { + let db; let cleanup; let app; + let superTok; let mgrTok; + + const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`); + const readSiteUrl = async () => { + const row = await db('app_settings').where({ setting_key: 'general_site_url' }).first(); + return row ? JSON.parse(row.setting_value) : null; + }; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId: superId } = await seedMinimal(db); + await assignAdminRole(db, superId, 'super_admin'); + superTok = mintAdminToken(superId); + + // Office-manager role: settings.view + settings.edit, NOT settings.domains. + const mgrRole = await svc.createRole( + { name: 'office_mgr', permissions: ['settings.view', 'settings.edit'] }, + superId, + ); + const ins = await db('admin_users').insert({ + username: 'office', email: 'office@example.com', password_hash: 'x', + role_id: mgrRole.id, must_change_password: false, created_at: new Date(), + }).returning('id'); + mgrTok = mintAdminToken(ins[0]?.id ?? ins[0]); + + await db('app_settings').insert({ + setting_key: 'general_site_url', setting_value: JSON.stringify(STORED_URL), setting_type: 'general', + }); + clearPermissionCache(); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/admin/settings', require('../../src/routes/adminSettings')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('settings.edit role can save /general when general_site_url is unchanged', async () => { + const res = await auth(request(app).put('/api/admin/settings/general'), mgrTok) + .send({ general_site_url: STORED_URL, general_max_file_size_mb: 50 }); + expect(res.status).not.toBe(403); + expect(res.status).toBe(200); + expect(await readSiteUrl()).toBe(STORED_URL); + }); + + it('settings.edit role is 403d when it actually changes general_site_url', async () => { + const res = await auth(request(app).put('/api/admin/settings/general'), mgrTok) + .send({ general_site_url: 'https://evil.example' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('FORBIDDEN'); + expect(res.body.keys.map((k) => k.key)).toContain('general_site_url'); + expect(await readSiteUrl()).toBe(STORED_URL); // unchanged + }); + + it('super_admin can change general_site_url', async () => { + const res = await auth(request(app).put('/api/admin/settings/general'), superTok) + .send({ general_site_url: 'https://new.example' }); + expect(res.status).toBe(200); + expect(await readSiteUrl()).toBe('https://new.example'); + }); +}); diff --git a/backend/__tests__/routes/authzPermissionGaps.test.js b/backend/__tests__/routes/authzPermissionGaps.test.js index b72a2548..46281d6b 100644 --- a/backend/__tests__/routes/authzPermissionGaps.test.js +++ b/backend/__tests__/routes/authzPermissionGaps.test.js @@ -51,11 +51,13 @@ describe('authorization / ownership gaps', () => { }).returning('id'); adminId = ins[0]?.id ?? ins[0]; await assignAdminRole(db, adminId, 'admin'); - // Grant settings.edit to the admin role BEFORE any request populates the - // 60s permission cache, so the revoke test exercises the ownership check - // (404) rather than the missing-permission gate (403). This models a - // custom role that carries settings.edit — the scenario GHSA-gprq needs. - await grantPermissionToRole('admin', 'settings.edit'); + // Grant settings.integrations to the admin role BEFORE any request populates + // the 60s permission cache, so the revoke test exercises the ownership check + // (404) rather than the missing-permission gate (403). Migration 174 split + // API-token management out of the catch-all settings.edit into the dedicated + // settings.integrations perm; this models a custom role that carries it — + // the scenario GHSA-gprq needs. + await grantPermissionToRole('admin', 'settings.integrations'); adminTok = mintAdminToken(adminId); app = express(); @@ -96,7 +98,7 @@ describe('authorization / ownership gaps', () => { expect(res.body.find((t) => t.id === superTokenId)).toBeDefined(); }); - it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => { + it('a non-owner (with settings.integrations) cannot revoke another admin\'s token', async () => { const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok); expect(res.status).toBe(404); const row = await db('api_tokens').where({ id: superTokenId }).first(); diff --git a/backend/migrations/core/175_granular_permissions_and_presets.js b/backend/migrations/core/175_granular_permissions_and_presets.js new file mode 100644 index 00000000..4446ebbd --- /dev/null +++ b/backend/migrations/core/175_granular_permissions_and_presets.js @@ -0,0 +1,256 @@ +/** + * Migration: Granular permissions + role presets + * + * Phase 1 of the multi-photographer epic (issues #743/#747): make every feature + * permission-gateable so multi-user studios can split capability across roles. + * + * This migration ONLY defines the permission catalog + seeds grants/presets. The + * route-level enforcement (pointing endpoints at the new perms) lives in the + * route files. Splitting the perms here is inert until a route references them. + * + * What it does: + * 1. Split the catch-all `settings.edit` into dedicated DANGEROUS-config perms + * (banking / domains / security / integrations / features) so a team member + * can be granted day-to-day settings without the ability to break IBAN, + * domains, SSO, webhooks or feature toggles. Grants project forward from + * every role that holds `settings.edit` today — nobody loses capability on + * upgrade (see feedback_permission_split_compat). Today `settings.edit` is + * super_admin-only, so in practice only super_admin gains the split perms. + * 2. Add dedicated view/manage perms for feature areas that previously borrowed + * generic perms (calendar, deals, transfers, whatsapp, tax report, vat codes, + * event types, short urls, css templates, image security, notifications, + * system) so each can be carved into a custom role independently. + * 3. Grant every new perm to `super_admin` (the tracks-all owner). The boot + * self-heal (_permissionsBoot.js) keeps super_admin complete on every future + * release, so a new perm never needs a compensation migration. + * 4. Seed the `solo_photographer` preset role — a full operator (studio owner) + * granted every current permission. It is a frozen preset: future-release + * perms are NOT auto-added (only super_admin tracks all); the owner grants + * them via the role editor if wanted. + * + * Idempotent throughout (existing-name / existing-grant checks) so a re-run or a + * partially-applied state is safe. + */ + +// --------------------------------------------------------------------------- +// New DANGEROUS-config permissions split out of `settings.edit`. +// `settings.edit` is retained as the SAFE everyday bucket (branding text, +// general prefs, display formats, SEO/analytics copy, public-site content). +// --------------------------------------------------------------------------- +const SETTINGS_SPLIT_PERMISSIONS = [ + { name: 'settings.banking', display_name: 'Manage Banking & Payment Config', category: 'settings', description: 'Edit bank accounts / IBAN, QR-bill, the issuer block and VAT/accounting config. Sensitive — governs where money is collected.' }, + { name: 'settings.domains', display_name: 'Manage Domain & URL Config', category: 'settings', description: 'Edit the public base URL / domain settings used in links and emails.' }, + { name: 'settings.security', display_name: 'Manage Security & SSO Config', category: 'settings', description: 'Edit security policy, rate limits and single sign-on (OIDC) login settings.' }, + { name: 'settings.integrations', display_name: 'Manage Integrations', category: 'settings', description: 'Manage outbound webhooks and API tokens.' }, + { name: 'settings.features', display_name: 'Manage Feature Toggles', category: 'settings', description: 'Enable or disable application features (feature flags).' }, +]; + +// --------------------------------------------------------------------------- +// Dedicated per-feature permissions. view = day-to-day read, manage = configure. +// --------------------------------------------------------------------------- +// Dedicated perms for admin surfaces that were miscategorised under the generic +// `settings.*` bucket. Their writes were super_admin-only (settings.edit), so +// pointing them here doesn't strip any existing role. Areas that already have a +// sensible domain gate (calendar→customers, deals→customers/bills, +// transfers→events, tax report→bills, short URLs→events, css templates→branding) +// are intentionally left on those gates for now — finer carving there ships with +// the phase-2 photographer role (needs forward-projection to stay compat-safe). +const FEATURE_PERMISSIONS = [ + { name: 'whatsapp.view', display_name: 'View WhatsApp', category: 'whatsapp', description: 'View WhatsApp messaging status and config.' }, + { name: 'whatsapp.manage', display_name: 'Manage WhatsApp', category: 'whatsapp', description: 'Configure and send via WhatsApp.' }, + + { name: 'roles.manage', display_name: 'Manage Roles', category: 'users', description: 'Create, edit and delete roles and their permission sets. Highly privileged — a holder can grant any capability, so keep it to owners.' }, + + { name: 'vat_codes.view', display_name: 'View VAT Codes', category: 'accounting', description: 'Read the VAT-code list (used by document editors).' }, + + { name: 'event_types.view', display_name: 'View Event Types', category: 'events', description: 'Read the event-type list (used when creating events).' }, + { name: 'event_types.manage', display_name: 'Manage Event Types', category: 'events', description: 'Create, edit and delete event types.' }, + + { name: 'image_security.view', display_name: 'View Image Security', category: 'photos', description: 'View image-protection / watermarking settings and access logs.' }, + { name: 'image_security.manage',display_name: 'Manage Image Security', category: 'photos', description: 'Configure image protection, block IPs and clear access logs.' }, + + { name: 'notifications.view', display_name: 'View Notifications', category: 'system', description: 'View admin notifications.' }, + { name: 'notifications.manage', display_name: 'Manage Notifications', category: 'system', description: 'Mark read and clear admin notifications.' }, + + { name: 'system.view', display_name: 'View System', category: 'system', description: 'View system status, version, updates and health.' }, + { name: 'system.manage', display_name: 'Manage System', category: 'system', description: 'Configure update notifications and run system maintenance actions.' }, +]; + +const ALL_NEW_PERMISSIONS = [...SETTINGS_SPLIT_PERMISSIONS, ...FEATURE_PERMISSIONS]; + +// Preset roles shipped by the app. `permissions: 'ALL'` = every current perm. +// Both are frozen system roles: future-release perms are NOT auto-added (only +// super_admin tracks all); the owner tweaks them via the role editor. +const SOLO_PHOTOGRAPHER = { + name: 'solo_photographer', + display_name: 'Solo Photographer', + description: 'Full operator for a one-person studio — everything needed to run the business (galleries, uploads, CRM, invoices, banking, settings and team management). A preset starting point; new-release permissions are not auto-added (only Super Admin tracks all).', + is_system: true, + priority: 90, // between super_admin (100) and admin (80) + permissions: 'ALL', +}; + +// Team Photographer — a second/festival shooter who contributes photos but is +// NOT the customer contact: view events + upload/edit/download photos, plus +// read-only CRM context (customers/quotes/invoices). No settings, users, +// billing edits, or events.edit (so no transfers/projects/guests either). The +// "only their assigned events" scoping is phase 2 (#743 event assignment). +const TEAM_PHOTOGRAPHER = { + name: 'team_photographer', + display_name: 'Team Photographer', + description: 'Contributing photographer (second/festival shooter) — view events, upload and manage photos, and see read-only client context. Not the customer contact: no settings, user management, billing edits or event configuration. A preset starting point.', + is_system: true, + priority: 40, // between editor (50) and viewer (20) + permissions: [ + 'events.view', + 'photos.view', 'photos.upload', 'photos.edit', 'photos.download', + 'customers.view', 'quotes.view', 'bills.view', + ], +}; + +const PRESET_ROLES = [SOLO_PHOTOGRAPHER, TEAM_PHOTOGRAPHER]; + +exports.up = async function (knex) { + const hasPermissions = await knex.schema.hasTable('permissions'); + const hasRolePermissions = await knex.schema.hasTable('role_permissions'); + const hasRoles = await knex.schema.hasTable('roles'); + if (!hasPermissions || !hasRolePermissions || !hasRoles) { + console.log('175: RBAC tables missing, skipping permission seed'); + return; + } + + // 1. Insert all new permissions (skip any already present). + { + const existing = await knex('permissions') + .whereIn('name', ALL_NEW_PERMISSIONS.map((p) => p.name)) + .select('name'); + const existingSet = new Set(existing.map((r) => r.name)); + const toInsert = ALL_NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name)); + if (toInsert.length > 0) { + await knex('permissions').insert(toInsert); + console.log(`175: inserted ${toInsert.length} new permissions`); + } + } + + // Helper: insert (role_id, permission_id) grants without duplicates. + const grantPerms = async (roleId, permIds) => { + if (!roleId || permIds.length === 0) return; + const existing = await knex('role_permissions') + .where({ role_id: roleId }) + .whereIn('permission_id', permIds) + .select('permission_id'); + const have = new Set(existing.map((r) => r.permission_id)); + const inserts = permIds + .filter((id) => !have.has(id)) + .map((id) => ({ role_id: roleId, permission_id: id })); + if (inserts.length > 0) { + const batchSize = 50; + for (let i = 0; i < inserts.length; i += batchSize) { + await knex('role_permissions').insert(inserts.slice(i, i + batchSize)); + } + } + }; + + // 2. Project every perm that REPLACED a settings.edit gate forward: every role + // holding settings.edit today gets each of them, so nobody loses capability + // on upgrade (compat). This covers both the settings.* split AND the feature + // .manage perms that took over settings.edit WRITE gates (whatsapp, + // event_types, image_security, notifications, system) — projecting both + // lists keeps the pattern symmetric for future phase-2 settings.edit holders. + { + const SETTINGS_EDIT_REPLACEMENTS = [ + ...SETTINGS_SPLIT_PERMISSIONS.map((p) => p.name), + 'whatsapp.manage', 'event_types.manage', 'image_security.manage', + 'notifications.manage', 'system.manage', + ]; + const editPerm = await knex('permissions').where({ name: 'settings.edit' }).first(); + const replacementPerms = await knex('permissions') + .whereIn('name', SETTINGS_EDIT_REPLACEMENTS) + .select('id'); + const replacementIds = replacementPerms.map((p) => p.id); + if (editPerm && replacementIds.length > 0) { + const rolesWithEdit = await knex('role_permissions') + .where({ permission_id: editPerm.id }) + .select('role_id'); + for (const { role_id } of rolesWithEdit) { + await grantPerms(role_id, replacementIds); + } + } + } + + // 3. Grant EVERY new permission to super_admin (the tracks-all owner). + { + const superAdmin = await knex('roles').where({ name: 'super_admin' }).first(); + if (superAdmin) { + const newPerms = await knex('permissions') + .whereIn('name', ALL_NEW_PERMISSIONS.map((p) => p.name)) + .select('id'); + await grantPerms(superAdmin.id, newPerms.map((p) => p.id)); + } + } + + // 4. Seed the preset roles (Solo + Team Photographer). Each is created with + // its grant set ONLY when missing; an existing preset is left untouched + // (frozen — the owner may have customised it). + for (const preset of PRESET_ROLES) { + const existing = await knex('roles').where({ name: preset.name }).first(); + if (existing) continue; + await knex('roles').insert({ + name: preset.name, + display_name: preset.display_name, + description: preset.description, + is_system: preset.is_system, + priority: preset.priority, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + const role = await knex('roles').where({ name: preset.name }).first(); + let permIds; + if (preset.permissions === 'ALL') { + permIds = (await knex('permissions').select('id')).map((p) => p.id); + } else { + const rows = await knex('permissions').whereIn('name', preset.permissions).select('id'); + permIds = rows.map((p) => p.id); + } + await grantPerms(role.id, permIds); + console.log(`175: seeded ${preset.name} preset (${permIds.length} permissions)`); + } + + console.log('175: granular permissions + presets migration complete'); +}; + +exports.down = async function (knex) { + const hasPermissions = await knex.schema.hasTable('permissions'); + if (!hasPermissions) return; + + const hasRolePermissions = await knex.schema.hasTable('role_permissions'); + const names = ALL_NEW_PERMISSIONS.map((p) => p.name); + const perms = await knex('permissions').whereIn('name', names).select('id'); + const ids = perms.map((p) => p.id); + if (ids.length > 0) { + if (hasRolePermissions) await knex('role_permissions').whereIn('permission_id', ids).del(); + await knex('permissions').whereIn('id', ids).del(); + } + + // Remove the preset roles and their grants (each table guarded — the earlier + // hasTable('permissions') check does not imply roles/admin_users exist). + const hasRoles = await knex.schema.hasTable('roles'); + if (hasRoles) { + const hasAdminUsers = await knex.schema.hasTable('admin_users'); + for (const preset of PRESET_ROLES) { + const role = await knex('roles').where({ name: preset.name }).first(); + if (role) { + if (hasRolePermissions) await knex('role_permissions').where({ role_id: role.id }).del(); + if (hasAdminUsers) await knex('admin_users').where({ role_id: role.id }).update({ role_id: null }); + await knex('roles').where({ id: role.id }).del(); + } + } + } +}; + +module.exports.ALL_NEW_PERMISSIONS = ALL_NEW_PERMISSIONS; +module.exports.SETTINGS_SPLIT_PERMISSIONS = SETTINGS_SPLIT_PERMISSIONS; +module.exports.FEATURE_PERMISSIONS = FEATURE_PERMISSIONS; +module.exports.SOLO_PHOTOGRAPHER = SOLO_PHOTOGRAPHER; +module.exports.TEAM_PHOTOGRAPHER = TEAM_PHOTOGRAPHER; +module.exports.PRESET_ROLES = PRESET_ROLES; diff --git a/backend/server.js b/backend/server.js index 680e0615..9bdafa8a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -773,6 +773,7 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/users', require('./src/routes/adminUsers')); +app.use('/api/admin/roles', require('./src/routes/adminRoles')); // Customer portal (#354). The customerPortal feature flag is a // VISIBILITY toggle for the admin surface, not a kill switch for // customer access. Enforcement: @@ -1048,6 +1049,17 @@ async function startServer() { logger.warn('built-in workflow seed failed at boot:', err.message); } + // Self-heal the RBAC catalog: ensure super_admin holds every permission + // (the "Admin tracks all" guarantee) and the solo_photographer preset + // exists. New perms never need a compensation migration. See + // _permissionsBoot.js + project_permission_gating. + try { + const { seedPermissionsAtBoot } = require('./src/services/_permissionsBoot'); + await seedPermissionsAtBoot(db, logger); + } catch (err) { + logger.warn('permissions self-heal failed at boot:', err.message); + } + // Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or // `.txt`) exists in the /backup mount AND the DB is empty, run // the restore HERE before any admin UI surfaces. Lets admins diff --git a/backend/src/routes/adminApiTokens.js b/backend/src/routes/adminApiTokens.js index 3b625609..7c942df3 100644 --- a/backend/src/routes/adminApiTokens.js +++ b/backend/src/routes/adminApiTokens.js @@ -18,7 +18,7 @@ const router = express.Router(); // List tokens for the current admin (or all, if super_admin) — without // the plaintext, never recoverable after creation. -router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/', adminAuth, requirePermission(['settings.view', 'settings.integrations']), async (req, res) => { try { // Scope to the caller's own tokens unless super_admin — the previous // query returned every admin's token metadata (name/preview/scopes/ @@ -60,7 +60,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) router.post( '/', adminAuth, - requirePermission('settings.edit'), + requirePermission('settings.integrations'), [ body('name').isString().trim().isLength({ min: 1, max: 100 }), body('scopes').isArray({ min: 1 }).custom((arr) => { @@ -112,7 +112,7 @@ router.post( ); // Revoke a token (soft-delete; lookups still find it but reject). -router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.delete('/:id', adminAuth, requirePermission('settings.integrations'), async (req, res) => { try { const { id } = req.params; const row = await db('api_tokens').where({ id }).first(); diff --git a/backend/src/routes/adminBusinessProfile.js b/backend/src/routes/adminBusinessProfile.js index 4c833012..da53fd79 100644 --- a/backend/src/routes/adminBusinessProfile.js +++ b/backend/src/routes/adminBusinessProfile.js @@ -216,7 +216,7 @@ router.use(adminAuth); // ---- GET / ------------------------------------------------------------ router.get( '/', - requirePermission('settings.view'), + requirePermission(['settings.view', 'settings.banking']), handleAsync(async (req, res) => { const { profile, bankAccounts } = await businessProfileService.getProfile(); return successResponse(res, { @@ -235,7 +235,7 @@ router.get( // an existing file. Read-only — never modifies anything. router.get( '/logo-diagnostic', - requirePermission('settings.view'), + requirePermission(['settings.view', 'settings.banking']), handleAsync(async (req, res) => { const fs = require('fs'); const path = require('path'); @@ -345,7 +345,7 @@ router.get( // branding_logo_path still applies when this is unset. router.post( '/logo', - requirePermission('settings.edit'), + requirePermission('settings.banking'), pdfLogoUpload.single('logo'), handleAsync(async (req, res) => { if (!req.file) { @@ -380,7 +380,7 @@ router.post( router.delete( '/logo', - requirePermission('settings.edit'), + requirePermission('settings.banking'), handleAsync(async (req, res) => { const existing = await db('business_profile').where({ id: 1 }).first(); const prev = existing?.logo_path; @@ -402,7 +402,7 @@ router.delete( // ---- PUT / ------------------------------------------------------------ router.put( '/', - requirePermission('settings.edit'), + requirePermission('settings.banking'), [ // All fields optional — partial update is fine. We only run shallow // shape validation on the types that absolutely must be sane; @@ -525,7 +525,7 @@ router.put( // ---- bank accounts ---------------------------------------------------- router.get( '/bank-accounts', - requirePermission('settings.view'), + requirePermission(['settings.view', 'settings.banking']), handleAsync(async (req, res) => { const { bankAccounts } = await businessProfileService.getProfile(); return successResponse(res, { bankAccounts: bankAccounts.map(transformBank) }); @@ -534,7 +534,7 @@ router.get( router.post( '/bank-accounts', - requirePermission('settings.edit'), + requirePermission('settings.banking'), [ body('iban').isString().isLength({ min: 5, max: 64 }).withMessage('IBAN is required') .bail().custom(ibanValidator({ required: true })), @@ -562,7 +562,7 @@ router.post( router.put( '/bank-accounts/:id', - requirePermission('settings.edit'), + requirePermission('settings.banking'), [ param('id').isInt({ min: 1 }), body('iban').optional({ values: 'falsy' }).isString().isLength({ min: 5, max: 64 }) @@ -599,7 +599,7 @@ router.put( router.delete( '/bank-accounts/:id', - requirePermission('settings.edit'), + requirePermission('settings.banking'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => { validateRequest(req); diff --git a/backend/src/routes/adminEventTypes.js b/backend/src/routes/adminEventTypes.js index c3ef780a..786de49c 100644 --- a/backend/src/routes/adminEventTypes.js +++ b/backend/src/routes/adminEventTypes.js @@ -19,7 +19,7 @@ const router = express.Router(); * GET /admin/event-types * Get all event types (for admin management) */ -router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/', adminAuth, requirePermission(['settings.view', 'event_types.view', 'events.create', 'events.edit']), async (req, res) => { try { const includeInactive = req.query.includeInactive === 'true'; const eventTypes = await eventTypeService.getAllEventTypes({ @@ -51,7 +51,7 @@ router.get('/active', adminAuth, async (req, res) => { * GET /admin/event-types/:id * Get a single event type by ID */ -router.get('/:id', adminAuth, requirePermission('settings.view'), [ +router.get('/:id', adminAuth, requirePermission(['settings.view', 'event_types.view', 'events.create', 'events.edit']), [ param('id').isInt().withMessage('Invalid event type ID') ], async (req, res) => { try { @@ -78,7 +78,7 @@ router.get('/:id', adminAuth, requirePermission('settings.view'), [ * POST /admin/event-types * Create a new event type */ -router.post('/', adminAuth, requirePermission('settings.edit'), [ +router.post('/', adminAuth, requirePermission('event_types.manage'), [ body('name').notEmpty().trim().withMessage('Name is required'), body('slug_prefix') .notEmpty() @@ -138,7 +138,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ * PUT /admin/event-types/:id * Update an event type */ -router.put('/:id', adminAuth, requirePermission('settings.edit'), [ +router.put('/:id', adminAuth, requirePermission('event_types.manage'), [ param('id').isInt().withMessage('Invalid event type ID'), body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'), body('slug_prefix') @@ -190,7 +190,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [ * DELETE /admin/event-types/:id * Delete an event type (only non-system types with no events) */ -router.delete('/:id', adminAuth, requirePermission('settings.edit'), [ +router.delete('/:id', adminAuth, requirePermission('event_types.manage'), [ param('id').isInt().withMessage('Invalid event type ID') ], async (req, res) => { try { @@ -228,7 +228,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [ * POST /admin/event-types/reorder * Reorder event types by providing an array of IDs in the desired order */ -router.post('/reorder', adminAuth, requirePermission('settings.edit'), [ +router.post('/reorder', adminAuth, requirePermission('event_types.manage'), [ body('orderedIds').isArray().withMessage('orderedIds must be an array'), body('orderedIds.*').isInt().withMessage('Each ID must be an integer') ], async (req, res) => { diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 9e333407..d488c482 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -189,7 +189,7 @@ function applyDependencyRules(flags) { return out; } -router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/', adminAuth, requirePermission(['settings.view', 'settings.features']), async (req, res) => { try { const flags = await readAllFlags(); // Always run the rules so derived flags (e.g. `clients`) and @@ -202,7 +202,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) } }); -router.put('/', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/', adminAuth, requirePermission('settings.features'), async (req, res) => { try { const body = req.body || {}; if (typeof body !== 'object' || Array.isArray(body)) { diff --git a/backend/src/routes/adminImageSecurity.js b/backend/src/routes/adminImageSecurity.js index 6fb15632..35699dd6 100644 --- a/backend/src/routes/adminImageSecurity.js +++ b/backend/src/routes/adminImageSecurity.js @@ -10,7 +10,7 @@ const router = express.Router(); /** * Get image security settings */ -router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/settings', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => { try { const settings = await db('app_settings') .whereIn('setting_key', [ @@ -47,7 +47,7 @@ router.get('/settings', adminAuth, requirePermission('settings.view'), async (re /** * Update image security settings */ -router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/settings', adminAuth, requirePermission('image_security.manage'), async (req, res) => { try { const updates = req.body; @@ -98,7 +98,7 @@ router.put('/settings', adminAuth, requirePermission('settings.edit'), async (re /** * Get security monitoring dashboard data */ -router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/dashboard', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => { try { const { timeframe = '24h' } = req.query; @@ -203,7 +203,7 @@ router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (r /** * Get detailed security logs */ -router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/logs', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => { try { const { page = 1, @@ -272,7 +272,7 @@ router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, r /** * Get image access logs for a specific event */ -router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/events/:eventId/access-logs', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => { try { const { eventId } = req.params; const { page = 1, limit = 50 } = req.query; @@ -322,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, requirePermission('setting /** * Block/unblock suspicious IPs */ -router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.post('/block-ip', adminAuth, requirePermission('image_security.manage'), async (req, res) => { try { const { ip, action = 'block' } = req.body; @@ -368,7 +368,7 @@ router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (r /** * Clear security logs older than specified time */ -router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.delete('/logs/cleanup', adminAuth, requirePermission('image_security.manage'), async (req, res) => { try { const { olderThan = '30d' } = req.body; @@ -425,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), as /** * Export security data for analysis */ -router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/export', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => { try { const { format = 'json', timeframe = '7d' } = req.query; diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index 822e9427..42d6c9d2 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -6,7 +6,7 @@ const logger = require('../utils/logger'); const router = express.Router(); // Get notifications (unread activity logs) -router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/', adminAuth, requirePermission(['settings.view', 'notifications.view']), async (req, res) => { try { const { limit = 20, includeRead = false } = req.query; @@ -66,7 +66,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) }); // Mark notification as read -router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/:id/read', adminAuth, requirePermission('notifications.manage'), async (req, res) => { try { const { id } = req.params; @@ -84,7 +84,7 @@ router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (re }); // Mark all notifications as read -router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/read-all', adminAuth, requirePermission('notifications.manage'), async (req, res) => { try { await db('activity_logs') .whereNull('read_at') @@ -108,7 +108,7 @@ router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (re // nothing matched the date filter, so it was effectively a confusingly // named Clear All anyway. Drop the rename and the branching, return // the simple deletedCount the existing test (and frontend toast) expect. -router.delete('/clear-all', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.delete('/clear-all', adminAuth, requirePermission('notifications.manage'), async (req, res) => { try { const deletedCount = await db('activity_logs').delete(); res.json({ message: 'All notifications cleared', deletedCount }); diff --git a/backend/src/routes/adminRoles.js b/backend/src/routes/adminRoles.js new file mode 100644 index 00000000..3d5d1c04 --- /dev/null +++ b/backend/src/routes/adminRoles.js @@ -0,0 +1,125 @@ +/** + * Role editor API — create/edit/delete roles and their permission sets, and + * read the permission catalog for the matrix UI. + * + * Mounted at /api/admin/roles. Every mutation is gated by the highly-privileged + * `roles.manage` permission (held by super_admin + the solo_photographer preset, + * and grantable to a custom role by the owner). Reads allow `users.view` too so + * the User Management page can show role details without role-editing rights. + * + * System roles (super_admin/admin/editor/viewer/solo_photographer/ + * team_photographer) are protected in the service layer: they can't be deleted + * or renamed, and super_admin's permission set is immutable (the boot self-heal + * keeps it complete regardless). + * + * See project_permission_gating. + */ +const express = require('express'); +const { body, param } = require('express-validator'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const userManagementService = require('../services/userManagementService'); + +const router = express.Router(); + +const READ_ROLES = ['users.view', 'roles.manage']; + +function transformRole(role) { + return { + id: role.id, + name: role.name, + displayName: role.display_name, + description: role.description, + isSystem: role.is_system === true || role.is_system === 1, + priority: role.priority, + userCount: role.user_count, + permissions: role.permissions || [], + }; +} + +// GET / — list roles with their permissions + user counts +router.get('/', adminAuth, requirePermission(READ_ROLES), handleAsync(async (req, res) => { + const roles = await userManagementService.getRolesWithPermissions(); + return successResponse(res, { roles: roles.map(transformRole) }); +})); + +// GET /permissions — the full permission catalog (for the matrix) +router.get('/permissions', adminAuth, requirePermission(READ_ROLES), handleAsync(async (req, res) => { + const permissions = await userManagementService.getPermissionCatalog(); + return successResponse(res, { permissions }); +})); + +// POST / — create a custom role +router.post('/', [ + adminAuth, + requirePermission('roles.manage'), + body('name').isString().trim().isLength({ min: 2, max: 49 }), + body('displayName').optional().isString().trim().isLength({ min: 1, max: 150 }), + body('description').optional({ nullable: true }).isString().isLength({ max: 500 }), + body('priority').optional().isInt({ min: 0, max: 99 }), + body('permissions').optional().isArray(), + body('permissions.*').optional().isString(), +], handleAsync(async (req, res) => { + validateRequest(req); + const role = await userManagementService.createRole({ + name: req.body.name, + displayName: req.body.displayName, + description: req.body.description, + priority: req.body.priority, + permissions: req.body.permissions || [], + }, req.admin.id); + return successResponse(res, { role: transformRole(role) }, 201); +})); + +// POST /:id/clone — clone a role (e.g. start from a preset) +router.post('/:id/clone', [ + adminAuth, + requirePermission('roles.manage'), + param('id').isInt(), + body('name').isString().trim().isLength({ min: 2, max: 49 }), + body('displayName').optional().isString().trim().isLength({ min: 1, max: 150 }), + body('description').optional({ nullable: true }).isString().isLength({ max: 500 }), +], handleAsync(async (req, res) => { + validateRequest(req); + const role = await userManagementService.cloneRole(Number(req.params.id), { + name: req.body.name, + displayName: req.body.displayName, + description: req.body.description, + }, req.admin.id); + return successResponse(res, { role: transformRole(role) }, 201); +})); + +// PUT /:id — update display/description/priority and/or permission set +router.put('/:id', [ + adminAuth, + requirePermission('roles.manage'), + param('id').isInt(), + body('displayName').optional().isString().trim().isLength({ min: 1, max: 150 }), + body('description').optional({ nullable: true }).isString().isLength({ max: 500 }), + body('priority').optional().isInt({ min: 0, max: 99 }), + body('permissions').optional().isArray(), + body('permissions.*').optional().isString(), +], handleAsync(async (req, res) => { + validateRequest(req); + const role = await userManagementService.updateRole(Number(req.params.id), { + displayName: req.body.displayName, + description: req.body.description, + priority: req.body.priority, + permissions: req.body.permissions, + }, req.admin.id); + return successResponse(res, { role: transformRole(role) }); +})); + +// DELETE /:id — delete a custom role +router.delete('/:id', [ + adminAuth, + requirePermission('roles.manage'), + param('id').isInt(), +], handleAsync(async (req, res) => { + validateRequest(req); + await userManagementService.deleteRole(Number(req.params.id), req.admin.id); + return successResponse(res, { success: true }); +})); + +module.exports = router; diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index f13c5f68..6355078b 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -6,7 +6,7 @@ const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); -const { requirePermission } = require('../middleware/permissions'); +const { requirePermission, userHasAnyPermission } = require('../middleware/permissions'); const { clearMaintenanceCache } = require('../middleware/maintenance'); const { clearSettingsCache } = require('../services/rateLimitService'); const { @@ -65,6 +65,63 @@ const stripReservedSettingKeys = (settings) => { return settings; }; +// Migration 174 hardening — per-key permission boundary for the GENERIC settings +// writers. /general, /analytics, /seo and /security all upsert arbitrary +// setting_keys, so without this a role holding only the broad `settings.edit` +// (or `settings.security`) could set keys owned by a NARROWER permission — +// repointing the public site URL, security policy, or VAT/accounting config — +// via the wrong endpoint, defeating the settings.edit split. Any protected key +// the caller isn't permitted to write is stripped before the upsert. The +// dedicated routes still work because their caller holds the matching perm +// (e.g. PUT /accounting is gated by settings.banking, so accounting_* survives). +const PROTECTED_SETTING_KEY_PERMS = [ + { match: (k) => k === 'general_site_url', perm: 'settings.domains' }, + { match: (k) => k.startsWith('security_'), perm: 'settings.security' }, + { match: (k) => k.startsWith('accounting_'), perm: 'settings.banking' }, +]; +// Returns the list of {key, perm} the caller tried to CHANGE without the owning +// permission. Callers 403 when it's non-empty rather than silently no-op'ing a +// permission boundary. Change-detection matters: the General tab re-posts +// general_site_url on every save, so a no-op round-trip of the stored value must +// not 403 an otherwise-safe settings.edit save (the office-manager role this PR +// exists to enable) — only an actual change is rejected. A denied key the caller +// couldn't change is left in `settings` (the request 403s before the upsert); an +// unauthorized no-op is dropped so the rest of the save proceeds. +const collectUnauthorizedProtectedKeys = async (settings, adminId) => { + const denied = []; + for (const key of Object.keys(settings)) { + const rule = PROTECTED_SETTING_KEY_PERMS.find((r) => r.match(key)); + if (!rule) continue; + if (await userHasAnyPermission(adminId, [rule.perm])) continue; + const row = await db('app_settings').where({ setting_key: key }).first(); + let stored = null; + if (row) { + try { stored = JSON.parse(row.setting_value); } catch (_) { stored = row.setting_value; } + } + if (String(stored ?? '') === String(settings[key] ?? '')) { + delete settings[key]; // unchanged — let the rest of the save through + continue; + } + denied.push({ key, perm: rule.perm }); + } + return denied; +}; +// Express helper: 403 (naming the keys + required perms) when the caller tried +// to write a protected key they don't hold; returns true if the request was +// rejected so the route can stop. +const rejectUnauthorizedProtectedKeys = async (settings, req, res) => { + const denied = await collectUnauthorizedProtectedKeys(settings, req.admin.id); + if (denied.length > 0) { + res.status(403).json({ + error: `You don't have permission to change: ${denied.map((d) => d.key).join(', ')}`, + code: 'FORBIDDEN', + keys: denied, + }); + return true; + } + return false; +}; + // Configure multer for logo uploads const storage = multer.diskStorage({ destination: async (req, file, cb) => { @@ -288,7 +345,8 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a // Accounting settings (km rate, per-diem rate, require-proof). Read via the // generic GET /:type ('accounting'); this is the typed write. Rates are // integer minor units; verify legal/tax guidance with a Treuhaender. -router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (req, res) => { +// Migration 174: VAT/accounting config is money-adjacent → settings.banking. +router.put('/accounting', adminAuth, requirePermission('settings.banking'), async (req, res) => { try { const updates = []; const setInt = (key) => { @@ -577,7 +635,8 @@ router.put('/downloads', adminAuth, requirePermission('settings.edit'), async (r // Read the SSO config. The secret is redacted to a set/unset flag; the // computed redirect URI is included for copy-paste into the IdP client. -router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, res) => { +// Migration 174: SSO/OIDC + security config → settings.security. +router.get('/sso', adminAuth, requirePermission(['settings.view', 'settings.security']), async (req, res) => { try { const oidcService = require('../services/oidcService'); const cfg = await oidcService.getOidcConfig(); @@ -610,7 +669,7 @@ router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, re } }); -router.put('/sso', adminAuth, requirePermission('settings.edit'), [ +router.put('/sso', adminAuth, requirePermission('settings.security'), [ body('oidc_enabled').optional().isBoolean(), body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }), body('oidc_client_id').optional().isString().trim(), @@ -717,7 +776,7 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [ // Server-side discovery probe: confirms the issuer is reachable and speaks // OIDC before the admin flips the enable toggle. Uses the SAVED config. -router.post('/sso/test', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.post('/sso/test', adminAuth, requirePermission('settings.security'), async (req, res) => { try { const oidcService = require('../services/oidcService'); const cfg = await oidcService.getOidcConfig(); @@ -1300,6 +1359,12 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req const settings = stripReservedSettingKeys({ ...req.body }); let uploadLimitTouched = false; + // Migration 174: drop any protected key (site URL / security / accounting) + // the caller isn't permitted to write, so the settings.edit bucket can't be + // used to repoint the install via this generic writer. See + // rejectUnauthorizedProtectedKeys (403s when a protected key is denied). + if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return; + const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_')); if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) { @@ -1425,9 +1490,11 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req }); // Update security settings -router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/security', adminAuth, requirePermission('settings.security'), async (req, res) => { try { const settings = stripReservedSettingKeys({ ...req.body }); + // A settings.security holder still can't write domain/accounting keys here. + if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return; // Update or insert each setting for (const [key, value] of Object.entries(settings)) { @@ -1466,6 +1533,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const settings = stripReservedSettingKeys({ ...req.body }); + if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return; // Validate the provider switch (#663 Phase 1). Reject unknown values // so the dashboard route's factory doesn't have to defensively guard. @@ -1521,6 +1589,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const settings = stripReservedSettingKeys({ ...req.body }); + if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return; // Validate seo_blocked_ai_agents is an array of strings if (settings.seo_blocked_ai_agents !== undefined) { @@ -1845,7 +1914,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp }); // Update rate limit settings -router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [ +router.put('/security/rate-limit', adminAuth, requirePermission('settings.security'), [ body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'), body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'), body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'), diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index f4e8c8b0..18dd492f 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -20,7 +20,7 @@ const { const router = express.Router(); // Get system version -router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/version', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { // Read backend version from package.json let backendVersion = '1.0.0'; @@ -49,7 +49,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req }); // Check for updates -router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/updates', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { // Check if update checking is enabled const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false'; @@ -92,7 +92,7 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req // brand-new install initialises the marker silently so it never pops // "what's new" with nothing to compare against. Best-effort: any failure // (GitHub unreachable, etc.) returns hasNews:false, never errors. -router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/updates/whatsnew', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { if (process.env.UPDATE_CHECK_ENABLED === 'false') { return res.json({ enabled: false, hasNews: false }); @@ -137,7 +137,7 @@ router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), a // Acknowledge the What's New — advance the per-instance marker to the // running version so it stops showing for every admin. -router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.post('/updates/whatsnew/seen', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { const running = await getCurrentVersion(); await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system'); @@ -153,7 +153,7 @@ router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.vie // admin can read release notes for ALL versions they're behind on, not // just the latest. Body is raw GitHub-flavoured markdown; rendering is // the client's job (frontend uses `marked`). -router.get('/updates/changelog', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/updates/changelog', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false'; if (!updateCheckEnabled) { @@ -176,7 +176,7 @@ router.get('/updates/changelog', adminAuth, requirePermission('settings.view'), }); // Get update instructions for current environment -router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/updates/instructions', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { // Check if update checking is enabled const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false'; @@ -217,7 +217,7 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view' }); // Get comprehensive system status -router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/status', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { // Database size. Read the LIVE connection rather than re-deriving any of // this from the environment (#1038): DATABASE_CLIENT is not the only thing @@ -351,7 +351,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req, }); // Get database statistics -router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/database', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { // Get table info const tables = [ @@ -413,7 +413,7 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re }); // Get update notification settings -router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/updates/notifications', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { const settings = await getUpdateNotificationSettings(); res.json(settings); @@ -424,7 +424,7 @@ router.get('/updates/notifications', adminAuth, requirePermission('settings.view }); // Update notification settings -router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/updates/notifications', adminAuth, requirePermission('system.manage'), async (req, res) => { try { const { enabled, recipients } = req.body; @@ -460,7 +460,7 @@ router.put('/updates/notifications', adminAuth, requirePermission('settings.edit // `version_update_available`, so admins on the latest version can still // verify their SMTP + recipient config — the previous handler bailed // with "No updates available" when nothing was pending (#418). -router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.post('/updates/notifications/send', adminAuth, requirePermission('system.manage'), async (req, res) => { try { const result = await sendTestUpdateNotification(); res.json(result); @@ -471,7 +471,7 @@ router.post('/updates/notifications/send', adminAuth, requirePermission('setting }); // Check and send update notifications (called on admin login or periodically) -router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.post('/updates/notifications/check', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => { try { const result = await checkAndNotifyUpdates(); res.json(result); diff --git a/backend/src/routes/adminSystemHealth.js b/backend/src/routes/adminSystemHealth.js index 26b16223..c1a60556 100644 --- a/backend/src/routes/adminSystemHealth.js +++ b/backend/src/routes/adminSystemHealth.js @@ -33,7 +33,7 @@ const VALID_SCOPES = ['quote', 'contract', 'contract-signature', 'invoice']; router.get( '/backup-integrity', - requirePermission('settings.view'), + requirePermission(['settings.view', 'system.view']), [ // CSV string like `?scope=contract,invoice`. Each member must be // one of the four known scopes. Empty / omitted means full scan. @@ -78,7 +78,7 @@ router.get( */ router.get( '/backup-coverage', - requirePermission('settings.view'), + requirePermission(['settings.view', 'system.view']), handleAsync(async (req, res) => { const report = await getCoverageReport(); return successResponse(res, { report }); @@ -97,7 +97,7 @@ router.get( */ router.get( '/failures', - requirePermission('settings.view'), + requirePermission(['settings.view', 'system.view']), handleAsync(async (req, res) => { const stuckEmails = await db('email_queue') .where(function () { @@ -132,7 +132,7 @@ router.get( */ router.post( '/failures/email/:id/retry', - requirePermission('settings.edit'), + requirePermission('system.manage'), handleAsync(async (req, res) => { const id = parseInt(req.params.id, 10); if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); @@ -153,7 +153,7 @@ router.post( */ router.delete( '/failures/email/:id', - requirePermission('settings.edit'), + requirePermission('system.manage'), handleAsync(async (req, res) => { const id = parseInt(req.params.id, 10); if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); diff --git a/backend/src/routes/adminVatCodes.js b/backend/src/routes/adminVatCodes.js index d513bc8b..518b3e91 100644 --- a/backend/src/routes/adminVatCodes.js +++ b/backend/src/routes/adminVatCodes.js @@ -11,12 +11,25 @@ */ const express = require('express'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { handleAsync, successResponse } = require('../utils/routeHelpers'); const ledgerService = require('../services/ledgerService'); const router = express.Router(); -router.get('/', adminAuth, handleAsync(async (req, res) => { +// Migration 174: the VAT-code list is reference data the invoice/quote editors +// read, so it must stay readable by anyone who can touch those documents. We +// gate it with a broad OR (its own vat_codes.view plus every CRM-editor perm) +// rather than a single narrow perm — completeness without breaking any editor. +const VAT_CODE_READERS = [ + 'vat_codes.view', + 'bills.view', 'bills.manage', + 'quotes.view', 'quotes.manage', + 'contracts.view', 'contracts.manage', + 'accounting.view', 'accounting.manage', +]; + +router.get('/', adminAuth, requirePermission(VAT_CODE_READERS), handleAsync(async (req, res) => { const items = await ledgerService.listVatCodes(); const active = items.filter((v) => v.active !== false); const { direction } = req.query; diff --git a/backend/src/routes/adminWebhooks.js b/backend/src/routes/adminWebhooks.js index 2ed920a6..ad83ce92 100644 --- a/backend/src/routes/adminWebhooks.js +++ b/backend/src/routes/adminWebhooks.js @@ -20,6 +20,10 @@ const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +// Migration 174: webhooks are an integration surface. Mutations require the +// dedicated `settings.integrations` perm (split out of the old catch-all +// `settings.edit`); reads allow either the general `settings.view` or the +// integration perm so a role scoped to integrations can still read them. const { validateExternalUrlAsync } = require('../utils/networkValidation'); const webhookService = require('../services/webhookService'); const logger = require('../utils/logger'); @@ -52,7 +56,7 @@ function safeJson(s, fallback) { } // ─── List ──────────────────────────────────────────────────────────────── -router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/', adminAuth, requirePermission(['settings.view', 'settings.integrations']), async (req, res) => { try { const rows = await db('webhooks') .leftJoin('admin_users', 'admin_users.id', 'webhooks.created_by') @@ -75,7 +79,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) router.post( '/', adminAuth, - requirePermission('settings.edit'), + requirePermission('settings.integrations'), [ body('name').isString().trim().isLength({ min: 1, max: 100 }), body('url').isString().isLength({ max: 2048 }).custom(async (url) => { @@ -142,7 +146,7 @@ router.post( ); // ─── Detail ────────────────────────────────────────────────────────────── -router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/:id', adminAuth, requirePermission(['settings.view', 'settings.integrations']), async (req, res) => { try { const row = await db('webhooks').where({ id: req.params.id }).first(); if (!row) return res.status(404).json({ error: 'Webhook not found' }); @@ -157,7 +161,7 @@ router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, re router.put( '/:id', adminAuth, - requirePermission('settings.edit'), + requirePermission('settings.integrations'), [ body('name').optional().isString().trim().isLength({ min: 1, max: 100 }), body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => { @@ -217,7 +221,7 @@ router.put( ); // ─── Delete ────────────────────────────────────────────────────────────── -router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.delete('/:id', adminAuth, requirePermission('settings.integrations'), async (req, res) => { try { const row = await db('webhooks').where({ id: req.params.id }).first(); if (!row) return res.status(404).json({ error: 'Webhook not found' }); @@ -236,7 +240,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, router.post( '/:id/test', adminAuth, - requirePermission('settings.edit'), + requirePermission('settings.integrations'), [body('event_type').optional().isIn(webhookService.EVENT_TYPES)], async (req, res) => { try { @@ -284,7 +288,7 @@ router.post( router.get( '/:id/deliveries', adminAuth, - requirePermission('settings.view'), + requirePermission(['settings.view', 'settings.integrations']), [ query('status').optional().isIn(['pending', 'success', 'failed']), query('page').optional().isInt({ min: 1 }), @@ -330,7 +334,7 @@ router.get( router.get( '/:id/deliveries/:deliveryId', adminAuth, - requirePermission('settings.view'), + requirePermission(['settings.view', 'settings.integrations']), async (req, res) => { try { const row = await db('webhook_deliveries') @@ -352,7 +356,7 @@ router.get( router.post( '/:id/deliveries/:deliveryId/replay', adminAuth, - requirePermission('settings.edit'), + requirePermission('settings.integrations'), async (req, res) => { try { const row = await db('webhook_deliveries') diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js index 7fa3bd22..457bd20d 100644 --- a/backend/src/routes/adminWhatsapp.js +++ b/backend/src/routes/adminWhatsapp.js @@ -33,7 +33,7 @@ const logger = require('../utils/logger'); // pattern). The Settings UI hides the tab as well; this is defence in depth. router.use(requireFeatureFlag('whatsapp')); -router.get('/config', adminAuth, requirePermission('settings.view'), async (req, res) => { +router.get('/config', adminAuth, requirePermission(['settings.view', 'whatsapp.view']), async (req, res) => { try { const config = await db('whatsapp_configs').first(); if (!config) { @@ -62,7 +62,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req, } }); -router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.put('/config', adminAuth, requirePermission('whatsapp.manage'), async (req, res) => { try { const { phone_number_id, waba_id, access_token, template_name, template_language, template_params, enabled } = req.body; @@ -142,7 +142,7 @@ router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, } }); -router.post('/test', adminAuth, requirePermission('settings.edit'), async (req, res) => { +router.post('/test', adminAuth, requirePermission('whatsapp.manage'), async (req, res) => { try { const { phone } = req.body; if (!phone) { diff --git a/backend/src/services/_permissionsBoot.js b/backend/src/services/_permissionsBoot.js new file mode 100644 index 00000000..7cd0d676 --- /dev/null +++ b/backend/src/services/_permissionsBoot.js @@ -0,0 +1,148 @@ +/** + * Boot-time self-heal for the RBAC permission catalog. + * + * The single durable guarantee here: the `super_admin` role holds EVERY + * permission currently defined. This is the "Admin tracks all" mechanism — + * whenever a future release adds a permission (via its migration), this boot + * pass grants it to super_admin automatically, so a new perm never needs a + * compensation migration and the owner is never locked out of a new feature. + * See feedback_self_heal_pattern + project_permission_gating. + * + * Deliberately NARROW: it only ever backfills `super_admin`. All other roles — + * admin, editor, viewer, the solo_photographer preset, and any custom roles an + * org creates — are FROZEN: new perms default OFF for them so nobody silently + * gains a capability (e.g. the ability to change IBAN/domains) on upgrade. The + * owner grants those explicitly via the role editor. + * + * The solo_photographer preset itself is seeded (with all-perms-at-seed-time) by + * migration 174; this pass only ensures it exists so a partially-migrated or + * hand-restored DB still shows the preset. Its grants are never re-synced. + * + * Idempotent and best-effort: any failure is logged and swallowed so a boot is + * never blocked by permission housekeeping. + */ + +// Preset roles shipped with the app. `permissions: 'ALL'` = every current perm. +// Kept in sync with migration 174 (PRESET_ROLES). This boot pass only ensures a +// preset EXISTS (create + grant if missing) so a partially-migrated or restored +// DB still shows it; it never re-syncs an existing preset's grants (frozen). +const PRESETS = [ + { + name: 'solo_photographer', + display_name: 'Solo Photographer', + description: 'Full operator for a one-person studio — everything needed to run the business. A preset starting point; new-release permissions are not auto-added (only Super Admin tracks all).', + is_system: true, + priority: 90, + permissions: 'ALL', + }, + { + name: 'team_photographer', + display_name: 'Team Photographer', + description: 'Contributing photographer (second/festival shooter) — view events, upload and manage photos, and see read-only client context. Not the customer contact: no settings, user management, billing edits or event configuration. A preset starting point.', + is_system: true, + priority: 40, + permissions: [ + 'events.view', + 'photos.view', 'photos.upload', 'photos.edit', 'photos.download', + 'customers.view', 'quotes.view', 'bills.view', + ], + }, +]; + +async function ensureSuperAdminHasAllPermissions(db, logger) { + const superAdmin = await db('roles').where({ name: 'super_admin' }).first(); + if (!superAdmin) return 0; + + const allPerms = await db('permissions').select('id'); + if (allPerms.length === 0) return 0; + + const held = await db('role_permissions') + .where({ role_id: superAdmin.id }) + .select('permission_id'); + const heldSet = new Set(held.map((r) => r.permission_id)); + + const inserts = allPerms + .filter((p) => !heldSet.has(p.id)) + .map((p) => ({ role_id: superAdmin.id, permission_id: p.id })); + + if (inserts.length > 0) { + const batchSize = 50; + for (let i = 0; i < inserts.length; i += batchSize) { + await db('role_permissions').insert(inserts.slice(i, i + batchSize)); + } + logger?.info?.(`Permissions self-heal: granted ${inserts.length} missing permission(s) to super_admin`); + } + return inserts.length; +} + +async function ensurePreset(db, logger, preset) { + const existing = await db('roles').where({ name: preset.name }).first(); + if (existing) return; // frozen — never re-sync its grants + + await db('roles').insert({ + name: preset.name, + display_name: preset.display_name, + description: preset.description, + is_system: preset.is_system, + priority: preset.priority, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }); + const role = await db('roles').where({ name: preset.name }).first(); + if (!role) return; + + let permIds; + if (preset.permissions === 'ALL') { + permIds = (await db('permissions').select('id')).map((p) => p.id); + } else { + const rows = await db('permissions').whereIn('name', preset.permissions).select('id'); + permIds = rows.map((p) => p.id); + } + if (permIds.length > 0) { + const inserts = permIds.map((id) => ({ role_id: role.id, permission_id: id })); + const batchSize = 50; + for (let i = 0; i < inserts.length; i += batchSize) { + await db('role_permissions').insert(inserts.slice(i, i + batchSize)); + } + } + logger?.info?.(`Permissions self-heal: seeded missing ${preset.name} preset (${permIds.length} permissions)`); +} + +async function seedPermissionsAtBoot(db, logger) { + try { + const hasRoles = await db.schema.hasTable('roles'); + const hasPerms = await db.schema.hasTable('permissions'); + const hasRolePerms = await db.schema.hasTable('role_permissions'); + if (!hasRoles || !hasPerms || !hasRolePerms) return; + + // Per-step try/catch: on a multi-replica start the loser of a + // role_permissions insert race can throw a PK violation in one step; that + // must not skip the remaining steps (e.g. preset seeding) on that replica. + let granted = 0; + try { + granted = await ensureSuperAdminHasAllPermissions(db, logger); + } catch (err) { + logger?.warn?.('Permissions self-heal: super_admin backfill failed:', err.message); + } + for (const preset of PRESETS) { + try { + await ensurePreset(db, logger, preset); + } catch (err) { + logger?.warn?.(`Permissions self-heal: preset ${preset.name} failed:`, err.message); + } + } + + if (granted > 0) { + // Drop the in-memory permission cache so the new grants take effect + // without waiting out the 60s TTL. + try { + const { clearPermissionCache } = require('../middleware/permissions'); + clearPermissionCache?.(); + } catch (_) { /* cache module optional at boot */ } + } + } catch (err) { + logger?.warn?.('Permissions self-heal failed at boot:', err.message); + } +} + +module.exports = { seedPermissionsAtBoot }; diff --git a/backend/src/services/userManagementService.js b/backend/src/services/userManagementService.js index dc95dee9..6fe19083 100644 --- a/backend/src/services/userManagementService.js +++ b/backend/src/services/userManagementService.js @@ -11,7 +11,7 @@ const { generateReadablePassword } = require('../utils/passwordGenerator'); const { getBcryptRounds } = require('../utils/passwordValidation'); const { queueEmail } = require('./emailProcessor'); const logger = require('../utils/logger'); -const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors'); +const { ConflictError, NotFoundError, ValidationError, ForbiddenError } = require('../utils/errors'); /** * Create a new admin user invitation @@ -567,6 +567,263 @@ async function validateInvitationToken(token) { return invitation || null; } +// --------------------------------------------------------------------------- +// Role management (the role editor). Highly privileged — every mutation here is +// gated by `roles.manage` at the route layer. See project_permission_gating. +// --------------------------------------------------------------------------- + +// System roles that ship with the app. Their `name` (the semantic key routes +// check) is immutable and they cannot be deleted; their display/description and +// (except super_admin) their permission set may be tweaked. +const RESERVED_ROLE_NAMES = ['super_admin', 'admin', 'editor', 'viewer', 'solo_photographer', 'team_photographer']; + +function clearPermCache() { + // Bust the RBAC middleware cache so grant changes take effect immediately + // rather than waiting out its 60s TTL. Lazy-required to avoid a load cycle. + try { require('../middleware/permissions').clearPermissionCache(); } catch (_) { /* optional */ } +} + +function normalizeRoleName(name) { + return String(name || '').trim().toLowerCase(); +} + +async function resolvePermissionIds(permissionNames) { + const names = Array.from(new Set((permissionNames || []).filter(Boolean))); + if (names.length === 0) return []; + const rows = await db('permissions').whereIn('name', names).select('id', 'name'); + const found = new Set(rows.map((r) => r.name)); + const missing = names.filter((n) => !found.has(n)); + if (missing.length > 0) { + throw new ValidationError(`Unknown permission(s): ${missing.join(', ')}`); + } + return rows.map((r) => r.id); +} + +// Contain the roles.manage blast radius (delegation, not root escalation): a +// non-super_admin managing roles may only grant permissions their OWN role +// already holds, so `roles.manage` can't be turned into "grant myself +// everything". super_admin bypasses (it holds the full catalog anyway). +async function assertActorMayGrant(actorId, permissionNames) { + const names = Array.from(new Set((permissionNames || []).filter(Boolean))); + if (names.length === 0) return; + const actor = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', actorId) + .select('roles.name as role_name') + .first(); + if (actor && actor.role_name === 'super_admin') return; + const { userHasAllPermissions } = require('../middleware/permissions'); + if (!(await userHasAllPermissions(actorId, names))) { + throw new ForbiddenError('You can only grant permissions your own role already holds.'); + } +} + +async function getRoleWithPermissionsById(id) { + const role = await db('roles') + .where('id', id) + .select('id', 'name', 'display_name', 'description', 'is_system', 'priority') + .first(); + if (!role) return null; + const permissions = await db('role_permissions') + .join('permissions', 'permissions.id', 'role_permissions.permission_id') + .where('role_permissions.role_id', id) + .pluck('permissions.name'); + return { ...role, permissions: permissions.sort() }; +} + +/** + * All roles with their permission-name arrays and assigned-user counts. Backs + * the role-editor list. + */ +async function getRolesWithPermissions() { + const roles = await db('roles') + .select('id', 'name', 'display_name', 'description', 'is_system', 'priority') + .orderBy('priority', 'desc'); + const grants = await db('role_permissions') + .join('permissions', 'permissions.id', 'role_permissions.permission_id') + .select('role_permissions.role_id as role_id', 'permissions.name as name'); + const userCounts = await db('admin_users') + .whereNotNull('role_id') + .select('role_id') + .count('* as count') + .groupBy('role_id'); + + const permsByRole = new Map(); + for (const g of grants) { + if (!permsByRole.has(g.role_id)) permsByRole.set(g.role_id, []); + permsByRole.get(g.role_id).push(g.name); + } + const countByRole = new Map(userCounts.map((r) => [r.role_id, Number(r.count)])); + + return roles.map((r) => ({ + ...r, + permissions: (permsByRole.get(r.id) || []).sort(), + user_count: countByRole.get(r.id) || 0, + })); +} + +/** + * The full permission catalog (for the editor's matrix), ordered by category. + */ +async function getPermissionCatalog() { + return db('permissions') + .select('id', 'name', 'display_name', 'category', 'description') + .orderBy(['category', 'name']); +} + +/** + * Create a custom role with an explicit permission set. + */ +async function createRole({ name, displayName, description, priority, permissions }, createdById) { + const normalized = normalizeRoleName(name); + if (!/^[a-z][a-z0-9_]{1,48}$/.test(normalized)) { + throw new ValidationError('Role name must be lowercase letters, numbers and underscores (2–49 chars, starting with a letter).'); + } + if (RESERVED_ROLE_NAMES.includes(normalized)) { + throw new ConflictError(`"${normalized}" is a reserved system role name.`); + } + const existing = await db('roles').where('name', normalized).first(); + if (existing) throw new ConflictError(`A role named "${normalized}" already exists.`); + + await assertActorMayGrant(createdById, permissions); + const permIds = await resolvePermissionIds(permissions); + const rawPriority = Number(priority); + const safePriority = Number.isFinite(rawPriority) ? Math.max(0, Math.min(99, Math.floor(rawPriority))) : 50; + + let roleId; + await db.transaction(async (trx) => { + const ins = await trx('roles').insert({ + name: normalized, + display_name: displayName || normalized, + description: description || null, + is_system: false, + priority: safePriority, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + }).returning('id'); + roleId = ins[0]?.id ?? ins[0]; + if (permIds.length > 0) { + await trx('role_permissions').insert(permIds.map((pid) => ({ role_id: roleId, permission_id: pid }))); + } + }); + + clearPermCache(); + // logActivity AFTER commit — a global-db write inside the trx would deadlock + // on SQLite. See feedback_sqlite_global_write_in_transaction. + await logActivity('admin_role_created', + { roleId, name: normalized, permissionCount: permIds.length }, + null, + { type: 'admin', id: createdById, name: 'system' }); + logger.info('Role created', { roleId, name: normalized, createdById }); + return getRoleWithPermissionsById(roleId); +} + +/** + * Update a role's display/description/priority and/or replace its permission + * set. super_admin is fully protected; system roles keep their name + priority. + */ +async function updateRole(id, { displayName, description, priority, permissions }, updatedById) { + const role = await db('roles').where('id', id).first(); + if (!role) throw new NotFoundError('Role', id); + if (role.name === 'super_admin') { + throw new ValidationError('The Super Admin role is protected — it always holds every permission and cannot be edited.'); + } + + // Self-amplification guard: a non-super_admin can't edit their OWN role (which + // would let a roles.manage holder grant their own role more), and can only + // grant permissions they already hold. See assertActorMayGrant. + const actor = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', updatedById) + .select('admin_users.role_id as role_id', 'roles.name as role_name') + .first(); + const actorIsSuper = actor && actor.role_name === 'super_admin'; + if (!actorIsSuper && actor && actor.role_id === Number(id)) { + throw new ForbiddenError('You cannot edit your own role.'); + } + if (permissions !== undefined) { + await assertActorMayGrant(updatedById, permissions); + } + + const patch = { updated_at: db.fn.now() }; + if (displayName !== undefined) patch.display_name = displayName; + if (description !== undefined) patch.description = description; + if (priority !== undefined && !role.is_system) { + const p = Number(priority); + if (Number.isFinite(p)) patch.priority = Math.max(0, Math.min(99, Math.floor(p))); + } + + let permIds = null; + if (permissions !== undefined) { + permIds = await resolvePermissionIds(permissions); + } + + await db.transaction(async (trx) => { + await trx('roles').where('id', id).update(patch); + if (permIds !== null) { + await trx('role_permissions').where('role_id', id).del(); + if (permIds.length > 0) { + await trx('role_permissions').insert(permIds.map((pid) => ({ role_id: id, permission_id: pid }))); + } + } + }); + + clearPermCache(); + await logActivity('admin_role_updated', + { roleId: id, name: role.name, permissionsChanged: permIds !== null }, + null, + { type: 'admin', id: updatedById, name: 'system' }); + logger.info('Role updated', { roleId: id, name: role.name, updatedById }); + return getRoleWithPermissionsById(id); +} + +/** + * Delete a custom role. System roles are protected; a role still assigned to + * users must be reassigned first (avoids leaving users permission-less). + */ +async function deleteRole(id, deletedById) { + const role = await db('roles').where('id', id).first(); + if (!role) throw new NotFoundError('Role', id); + if (role.is_system) throw new ValidationError('System roles cannot be deleted.'); + + const assigned = await db('admin_users').where('role_id', id).count('* as count').first(); + if (Number(assigned?.count) > 0) { + throw new ConflictError('Reassign the users holding this role before deleting it.'); + } + + await db.transaction(async (trx) => { + await trx('role_permissions').where('role_id', id).del(); + await trx('roles').where('id', id).del(); + }); + + clearPermCache(); + await logActivity('admin_role_deleted', + { roleId: id, name: role.name }, + null, + { type: 'admin', id: deletedById, name: 'system' }); + logger.info('Role deleted', { roleId: id, name: role.name, deletedById }); +} + +/** + * Clone any role (including a preset) into a new custom role with the same + * permission set — the "start from a preset" flow. + */ +async function cloneRole(sourceId, { name, displayName, description }, createdById) { + const source = await db('roles').where('id', sourceId).first(); + if (!source) throw new NotFoundError('Role', sourceId); + const permissions = await db('role_permissions') + .join('permissions', 'permissions.id', 'role_permissions.permission_id') + .where('role_permissions.role_id', sourceId) + .pluck('permissions.name'); + return createRole({ + name, + displayName: displayName || `${source.display_name} copy`, + description: description !== undefined ? description : source.description, + priority: source.is_system ? 50 : source.priority, + permissions, + }, createdById); +} + module.exports = { createInvitation, acceptInvitation, @@ -578,6 +835,13 @@ module.exports = { deleteAdminUser, resetAdminPassword, getAllRoles, + getRolesWithPermissions, + getPermissionCatalog, + getRoleWithPermissionsById, + createRole, + updateRole, + deleteRole, + cloneRole, getPendingInvitations, cancelInvitation, validateInvitationToken diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index d482a25a..eae384fb 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -10,6 +10,7 @@ import { uploadsService } from '../../services/uploads.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { getPhotoViewMode, setPhotoViewMode, type PhotoViewMode } from '../../utils/photoViewPrefs'; import { Button } from '../common'; +import { PermissionGate } from './PermissionGate'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { BulkCategoryModal } from './BulkCategoryModal'; @@ -209,50 +210,54 @@ export const AdminPhotoGrid: React.FC = ({ {t('gallery.photosSelected', { count: selectedPhotos.size })} - - - - + + + + + + + + )} @@ -420,19 +425,23 @@ export const AdminPhotoGrid: React.FC = ({ {!isSelectionMode && (
- - + + + + + +
)} @@ -666,21 +675,25 @@ export const AdminPhotoGrid: React.FC = ({ e.stopPropagation()}> {!isSelectionMode && (
- - + + + + + +
)} diff --git a/frontend/src/components/admin/RoleEditorModal.tsx b/frontend/src/components/admin/RoleEditorModal.tsx new file mode 100644 index 00000000..539f6653 --- /dev/null +++ b/frontend/src/components/admin/RoleEditorModal.tsx @@ -0,0 +1,285 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, Shield, Lock, Save } from 'lucide-react'; + +import { Button, Input, Card } from '../common'; +import type { PermissionDef, RoleWithPermissions } from '../../services/roles.service'; + +export interface RoleEditorSave { + name?: string; + displayName: string; + description?: string; + permissions: string[]; +} + +interface RoleEditorModalProps { + isOpen: boolean; + mode: 'create' | 'edit'; + role: RoleWithPermissions | null; // null for create + catalog: PermissionDef[]; + isLoading: boolean; + onClose: () => void; + onSave: (payload: RoleEditorSave) => void; +} + +// Friendly labels for permission categories (fallback: the raw category name). +const CATEGORY_LABELS: Record = { + events: 'Events', + photos: 'Photos', + archives: 'Archives', + analytics: 'Analytics', + email: 'Email', + branding: 'Branding', + cms: 'CMS Pages', + settings: 'Settings & Config', + backup: 'Backup & Restore', + users: 'Users & Roles', + activity: 'Activity Logs', + customers: 'Customers', + quotes: 'Quotes', + billing: 'Invoices', + contracts: 'Contracts', + accounting: 'Accounting', + workflows: 'Workflows', + whatsapp: 'WhatsApp', + system: 'System', +}; + +function categoryLabel(cat: string): string { + return CATEGORY_LABELS[cat] || cat.charAt(0).toUpperCase() + cat.slice(1); +} + +export const RoleEditorModal: React.FC = ({ + isOpen, + mode, + role, + catalog, + isLoading, + onClose, + onSave, +}) => { + const { t } = useTranslation(); + const isSuperAdmin = role?.name === 'super_admin'; + const readOnly = isSuperAdmin; // super_admin's permission set is immutable + + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [description, setDescription] = useState(''); + const [selected, setSelected] = useState>(new Set()); + const [nameError, setNameError] = useState(); + + useEffect(() => { + if (!isOpen) return; + setName(mode === 'edit' ? (role?.name ?? '') : ''); + setDisplayName(role?.displayName ?? ''); + setDescription(role?.description ?? ''); + setSelected(new Set(role?.permissions ?? [])); + setNameError(undefined); + }, [isOpen, mode, role]); + + // Group the catalog by category, preserving a stable order. + const grouped = useMemo(() => { + const map = new Map(); + for (const p of catalog) { + if (!map.has(p.category)) map.set(p.category, []); + map.get(p.category)!.push(p); + } + return Array.from(map.entries()).sort((a, b) => categoryLabel(a[0]).localeCompare(categoryLabel(b[0]))); + }, [catalog]); + + if (!isOpen) return null; + + const togglePerm = (permName: string) => { + if (readOnly) return; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(permName)) next.delete(permName); + else next.add(permName); + return next; + }); + }; + + const toggleCategory = (perms: PermissionDef[], allSelected: boolean) => { + if (readOnly) return; + setSelected((prev) => { + const next = new Set(prev); + for (const p of perms) { + if (allSelected) next.delete(p.name); + else next.add(p.name); + } + return next; + }); + }; + + const handleSave = () => { + if (mode === 'create') { + const normalized = name.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_]{1,48}$/.test(normalized)) { + setNameError(t('roleEditor.nameError', 'Use lowercase letters, numbers and underscores (2–49 chars, starting with a letter).')); + return; + } + } + onSave({ + name: mode === 'create' ? name.trim().toLowerCase() : undefined, + displayName: displayName.trim() || name.trim(), + description: description.trim(), + permissions: Array.from(selected), + }); + }; + + return ( +
+ +
+
+
+ +

+ {mode === 'create' + ? t('roleEditor.createTitle', 'Create role') + : t('roleEditor.editTitle', 'Edit role: {{name}}', { name: role?.displayName })} +

+
+ +
+ + {readOnly && ( +
+ +

+ {t('roleEditor.superAdminLocked', 'Super Admin always holds every permission and cannot be edited. It automatically gains new permissions as features are added.')} +

+
+ )} + + {/* Identity fields */} +
+
+ + setDisplayName(e.target.value)} + placeholder={t('roleEditor.displayNamePlaceholder', 'e.g. Photographer')} + disabled={isLoading || readOnly} + /> +
+
+ + { setName(e.target.value); setNameError(undefined); }} + placeholder="photographer" + disabled={isLoading || mode === 'edit'} + /> + {nameError &&

{nameError}

} + {mode === 'edit' && ( +

+ {t('roleEditor.keyLocked', 'The key is fixed once a role is created.')} +

+ )} +
+
+
+ + setDescription(e.target.value)} + placeholder={t('roleEditor.descriptionPlaceholder', 'What this role is for')} + disabled={isLoading || readOnly} + /> +
+ + {/* Permission matrix */} +
+

+ {t('roleEditor.permissions', 'Permissions')} +

+ + {t('roleEditor.selectedCount', '{{count}} selected', { count: selected.size })} + +
+ +
+ {grouped.map(([category, perms]) => { + const selectedInCat = perms.filter((p) => selected.has(p.name)).length; + const allSelected = selectedInCat === perms.length; + return ( +
+
+
+ {categoryLabel(category)} + {selectedInCat}/{perms.length} +
+ +
+
+ {perms.map((p) => { + const checked = selected.has(p.name); + return ( + + ); + })} +
+
+ ); + })} +
+
+ +
+ + {!readOnly && ( + + )} +
+
+
+ ); +}; + +RoleEditorModal.displayName = 'RoleEditorModal'; diff --git a/frontend/src/components/admin/RoleManagementTab.tsx b/frontend/src/components/admin/RoleManagementTab.tsx new file mode 100644 index 00000000..6c96b234 --- /dev/null +++ b/frontend/src/components/admin/RoleManagementTab.tsx @@ -0,0 +1,229 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { Shield, Plus, Edit, Copy, Trash2, Lock, Users as UsersIcon, AlertTriangle } from 'lucide-react'; + +import { Button, Card, Loading } from '../common'; +import { useMutationWithToast } from '../../hooks'; +import { rolesService, type RoleWithPermissions } from '../../services/roles.service'; +import { RoleEditorModal, type RoleEditorSave } from './RoleEditorModal'; + +const getRoleBadgeColor = (roleName: string): string => { + switch (roleName?.toLowerCase()) { + case 'super_admin': + return 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800'; + case 'admin': + case 'solo_photographer': + return 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'; + case 'editor': + return 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'; + default: + return 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 border-neutral-200 dark:border-neutral-600'; + } +}; + +export const RoleManagementTab: React.FC = () => { + const { t } = useTranslation(); + + const [editor, setEditor] = useState<{ mode: 'create' | 'edit'; role: RoleWithPermissions | null } | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data: roles, isLoading: rolesLoading } = useQuery({ + queryKey: ['admin-roles-full'], + queryFn: rolesService.getRoles, + }); + const { data: catalog, isLoading: catalogLoading } = useQuery({ + queryKey: ['admin-permission-catalog'], + queryFn: rolesService.getPermissionCatalog, + }); + + const invalidate: string[][] = [['admin-roles-full'], ['admin-roles']]; + + const createMutation = useMutationWithToast({ + mutationFn: (payload: RoleEditorSave) => + rolesService.createRole({ + name: payload.name!, + displayName: payload.displayName, + description: payload.description, + permissions: payload.permissions, + }), + invalidateKeys: invalidate, + successMessage: t('roleEditor.created', 'Role created'), + errorMessage: (e: Error) => e.message || t('roleEditor.saveError', 'Failed to save role'), + onSuccess: () => setEditor(null), + }); + + const updateMutation = useMutationWithToast({ + mutationFn: ({ id, payload }: { id: number; payload: RoleEditorSave }) => + rolesService.updateRole(id, { + displayName: payload.displayName, + description: payload.description, + permissions: payload.permissions, + }), + invalidateKeys: invalidate, + successMessage: t('roleEditor.updated', 'Role updated'), + errorMessage: (e: Error) => e.message || t('roleEditor.saveError', 'Failed to save role'), + onSuccess: () => setEditor(null), + }); + + const deleteMutation = useMutationWithToast({ + mutationFn: (id: number) => rolesService.deleteRole(id), + invalidateKeys: invalidate, + successMessage: t('roleEditor.deleted', 'Role deleted'), + errorMessage: (e: Error) => e.message || t('roleEditor.deleteError', 'Failed to delete role'), + onSuccess: () => setDeleteTarget(null), + }); + + const handleSave = (payload: RoleEditorSave) => { + if (editor?.mode === 'edit' && editor.role) { + updateMutation.mutate({ id: editor.role.id, payload }); + } else { + createMutation.mutate(payload); + } + }; + + if (rolesLoading || catalogLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ {t('roleEditor.subtitle', 'Define what each role can do. Start from a preset by cloning it, then trim or extend the permissions.')} +

+ +
+ +
+ {(roles || []).map((role) => { + const isSuperAdmin = role.name === 'super_admin'; + return ( + +
+
+
+ + + {role.displayName} + + {role.isSystem && ( + + + {t('roleEditor.systemRole', 'System')} + + )} +
+

{role.name}

+ {role.description && ( +

{role.description}

+ )} +
+
+ +
+ {t('roleEditor.permCount', '{{count}} permissions', { count: role.permissions.length })} + + + {t('roleEditor.userCount', '{{count}} users', { count: role.userCount })} + +
+ +
+ + + {!role.isSystem && ( + + )} +
+
+ ); + })} +
+ + {editor && ( + setEditor(null)} + onSave={handleSave} + /> + )} + + {deleteTarget && ( +
+ +
+
+
+ +
+
+

+ {t('roleEditor.confirmDelete.title', 'Delete role?')} +

+

+ {deleteTarget.userCount > 0 + ? t('roleEditor.confirmDelete.hasUsers', 'Reassign the {{count}} user(s) holding "{{name}}" before deleting it.', { count: deleteTarget.userCount, name: deleteTarget.displayName }) + : t('roleEditor.confirmDelete.message', 'Permanently delete the "{{name}}" role? This cannot be undone.', { name: deleteTarget.displayName })} +

+
+
+
+ + +
+
+
+
+ )} +
+ ); +}; + +RoleManagementTab.displayName = 'RoleManagementTab'; diff --git a/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx b/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx index a9dee398..b2813fa3 100644 --- a/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx +++ b/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx @@ -10,7 +10,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { AdminPhotoGrid } from '../AdminPhotoGrid'; import type { AdminPhoto } from '../../../services/photos.service'; @@ -39,6 +39,13 @@ vi.mock('../../../services/photos.service', () => ({ } })); +// AdminPhotoGrid now wraps its action buttons in PermissionGate (which needs a +// PermissionsProvider). This test is about the layout toggle, not gating, so +// stub the gate to a passthrough that always renders its children. +vi.mock('../PermissionGate', () => ({ + PermissionGate: ({ children }: { children: ReactNode }) => <>{children} +})); + const renderWithQueryClient = (ui: ReactElement) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2e0c1ab7..eb75a820 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -31,7 +31,8 @@ "cancel": "Abbrechen", "tabs": { "users": "Benutzer", - "invitations": "Einladungen" + "invitations": "Einladungen", + "roles": "Rollen" }, "stats": { "totalUsers": "Benutzer gesamt", @@ -5980,5 +5981,40 @@ "rebills": "offene Weiterverrechnungen", "hoursOnly": "Nur die Stunden", "rebillsOnly": "Nur die Weiterverrechnungen" + }, + "roleEditor": { + "loading": "Rollen werden geladen…", + "subtitle": "Lege fest, was jede Rolle darf. Beginne mit einer Vorlage, indem du sie duplizierst, und passe die Berechtigungen an.", + "newRole": "Neue Rolle", + "systemRole": "System", + "permCount": "{{count}} Berechtigungen", + "userCount": "{{count}} Benutzer", + "view": "Ansehen", + "clone": "Duplizieren", + "create": "Rolle erstellen", + "createTitle": "Rolle erstellen", + "editTitle": "Rolle bearbeiten: {{name}}", + "superAdminLocked": "Super Admin besitzt immer alle Berechtigungen und kann nicht bearbeitet werden. Neue Berechtigungen werden automatisch übernommen.", + "displayName": "Anzeigename", + "displayNamePlaceholder": "z. B. Fotograf", + "key": "Schlüssel (Kennung)", + "keyLocked": "Der Schlüssel ist nach dem Erstellen fest.", + "description": "Beschreibung", + "descriptionPlaceholder": "Wofür diese Rolle gedacht ist", + "permissions": "Berechtigungen", + "selectedCount": "{{count}} ausgewählt", + "selectAll": "Alle auswählen", + "clearAll": "Alle abwählen", + "nameError": "Nur Kleinbuchstaben, Zahlen und Unterstriche (2–49 Zeichen, beginnend mit einem Buchstaben).", + "created": "Rolle erstellt", + "updated": "Rolle aktualisiert", + "deleted": "Rolle gelöscht", + "saveError": "Rolle konnte nicht gespeichert werden", + "deleteError": "Rolle konnte nicht gelöscht werden", + "confirmDelete": { + "title": "Rolle löschen?", + "message": "Die Rolle \"{{name}}\" endgültig löschen? Dies kann nicht rückgängig gemacht werden.", + "hasUsers": "Weise die {{count}} Benutzer der Rolle \"{{name}}\" neu zu, bevor du sie löschst." + } } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0717d665..8a1b73c8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -31,7 +31,8 @@ "cancel": "Cancel", "tabs": { "users": "Users", - "invitations": "Invitations" + "invitations": "Invitations", + "roles": "Roles" }, "stats": { "totalUsers": "Total Users", @@ -5978,5 +5979,40 @@ "rebills": "open re-bills", "hoursOnly": "Just the hours", "rebillsOnly": "Just the re-bills" + }, + "roleEditor": { + "loading": "Loading roles…", + "subtitle": "Define what each role can do. Start from a preset by cloning it, then trim or extend the permissions.", + "newRole": "New role", + "systemRole": "System", + "permCount": "{{count}} permissions", + "userCount": "{{count}} users", + "view": "View", + "clone": "Clone", + "create": "Create role", + "createTitle": "Create role", + "editTitle": "Edit role: {{name}}", + "superAdminLocked": "Super Admin always holds every permission and cannot be edited. It automatically gains new permissions as features are added.", + "displayName": "Display name", + "displayNamePlaceholder": "e.g. Photographer", + "key": "Key (identifier)", + "keyLocked": "The key is fixed once a role is created.", + "description": "Description", + "descriptionPlaceholder": "What this role is for", + "permissions": "Permissions", + "selectedCount": "{{count}} selected", + "selectAll": "Select all", + "clearAll": "Clear all", + "nameError": "Use lowercase letters, numbers and underscores (2–49 chars, starting with a letter).", + "created": "Role created", + "updated": "Role updated", + "deleted": "Role deleted", + "saveError": "Failed to save role", + "deleteError": "Failed to delete role", + "confirmDelete": { + "title": "Delete role?", + "message": "Permanently delete the \"{{name}}\" role? This cannot be undone.", + "hasUsers": "Reassign the {{count}} user(s) holding \"{{name}}\" before deleting it." + } } } diff --git a/frontend/src/pages/admin/ArchivesPage.tsx b/frontend/src/pages/admin/ArchivesPage.tsx index 4f80f6f6..67ff5038 100644 --- a/frontend/src/pages/admin/ArchivesPage.tsx +++ b/frontend/src/pages/admin/ArchivesPage.tsx @@ -16,6 +16,7 @@ import { format, parseISO, isValid } from 'date-fns'; import { toast } from 'react-toastify'; import { Button, Input, Card, Loading } from '../../components/common'; +import { PermissionGate } from '../../components/admin/PermissionGate'; import { useQuery } from '@tanstack/react-query'; import { archiveService } from '../../services/archive.service'; import { useTranslation } from 'react-i18next'; @@ -302,34 +303,40 @@ export const ArchivesPage: React.FC = () => { Details */} - - - + + + + + + + + + diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index b323918d..0d631c34 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -26,6 +26,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common'; import { BulkArchiveModal, BulkDeleteModal } from '../../components/admin'; +import { PermissionGate } from '../../components/admin/PermissionGate'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService, type EventStatusFilter } from '../../services/events.service'; import { adminService } from '../../services/admin.service'; @@ -314,13 +315,15 @@ export const EventsListPage: React.FC = () => {

{t('events.title')}

{t('events.subtitle')}

- + + + {/* Statistics Cards — fed from /admin/dashboard/stats so the totals @@ -442,21 +445,25 @@ export const EventsListPage: React.FC = () => { - - + + + + + + )} @@ -665,44 +672,50 @@ export const EventsListPage: React.FC = () => { ) : null} {!event.is_archived ? ( - + + + ) : null} {event.is_archived ? ( + + + + ) : null} + - ) : null} - + )} diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index acdebae4..4337bd04 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -58,6 +58,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage'; import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage'; import { BlockLibraryPage } from './contracts/BlockLibraryPage'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; +import { usePermissions } from '../../contexts/PermissionsContext'; import { Briefcase, Receipt, ScrollText, Landmark, Smartphone, MonitorPlay } from 'lucide-react'; // Tab keys driving the inner-nav. Must include every key used in @@ -120,10 +121,56 @@ function isValidTab(value: string | null): value is TabType { return value !== null && (ALL_TAB_KEYS as string[]).includes(value); } +// Per-tab permission gating (multi-photographer permission project). Each tab is +// shown when the user holds ANY of the listed permissions. `settings.view` is in +// every set as the baseline "can read settings" grant, so admin/super_admin (who +// hold it) keep seeing every tab — no regression. A specialised role WITHOUT +// settings.view (e.g. a bookkeeper granted only settings.banking) reaches +// Settings via the broadened sidebar gate and sees only the tabs whose specific +// permission it holds. Backend routes enforce the same perms regardless of UI. +const TAB_PERMISSIONS: Record = { + features: ['settings.view', 'settings.features'], + general: ['settings.view', 'settings.domains'], + events: ['settings.view'], + eventTypes: ['settings.view', 'event_types.view', 'event_types.manage'], + branding: ['settings.view', 'branding.view', 'branding.edit'], + categories: ['settings.view'], + thumbnails: ['settings.view'], + downloads: ['settings.view'], + styling: ['settings.view', 'branding.edit'], + cms: ['settings.view', 'cms.view', 'cms.edit'], + email: ['settings.view', 'email.view', 'email.edit'], + moderation: ['settings.view'], + security: ['settings.view', 'settings.security'], + sso: ['settings.view', 'settings.security'], + imageSecurity: ['settings.view', 'image_security.view', 'image_security.manage'], + seo: ['settings.view'], + apiTokens: ['settings.view', 'settings.integrations'], + webhooks: ['settings.view', 'settings.integrations'], + status: ['settings.view', 'system.view', 'system.manage'], + analytics: ['settings.view', 'analytics.view'], + backup: ['settings.view', 'backup.view'], + businessProfile: ['settings.view', 'settings.banking'], + crm: ['settings.view'], + contracts: ['settings.view', 'contracts.view', 'contracts.manage'], + reminderTemplates: ['settings.view', 'email.view', 'email.edit'], + accounting: ['settings.view', 'settings.banking', 'accounting.view', 'accounting.manage'], + whatsapp: ['settings.view', 'whatsapp.view', 'whatsapp.manage'], + slideshow: ['settings.view'], +}; + +// The union of every settings-tab permission — used to decide whether to show +// the Settings entry in the sidebar for a specialised role that lacks the +// general settings.view read but holds one specific config permission. +export const SETTINGS_TAB_PERMISSIONS: string[] = Array.from( + new Set(Object.values(TAB_PERMISSIONS).flat()) +); + export const SettingsPage: React.FC = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const { flags, isLoading: flagsLoading } = useFeatureFlags(); + const { hasAnyPermission } = usePermissions(); // Read ?tab=… on mount; default to Features per the redesign. const initialTab: TabType = isValidTab(searchParams.get('tab')) @@ -219,6 +266,28 @@ export const SettingsPage: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow, activeTab]); + // Permission snap-back: if the active tab isn't permitted for this role (e.g. + // a deep-linked ?tab=security a photographer can't access), move to the first + // tab that is both permitted and not feature-flag-gated-off. Sits above the + // isLoading early return to keep hook ordering stable. + useEffect(() => { + if (flagsLoading) return; + if (hasAnyPermission(TAB_PERMISSIONS[activeTab] ?? ['settings.view'])) return; + const flagOff: Partial> = { + crm: !(flags.quotes || flags.bills || flags.contracts), + contracts: !flags.contracts, + reminderTemplates: !flags.reminderEmails, + accounting: !flags.accounting, + whatsapp: !flags.whatsapp, + slideshow: !flags.slideshow, + }; + const firstVisible = ALL_TAB_KEYS.find( + (k) => !flagOff[k] && hasAnyPermission(TAB_PERMISSIONS[k] ?? ['settings.view']) + ); + if (firstVisible && firstVisible !== activeTab) setActiveTab(firstVisible); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flagsLoading, activeTab, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow]); + if (isLoading) { return (
@@ -314,7 +383,14 @@ export const SettingsPage: React.FC = () => { }, ]; - const allItems = navGroups.flatMap((g) => g.items); + // Permission-filter each group's items, then drop groups left empty. A tab is + // shown when the user holds any of its TAB_PERMISSIONS (super_admin bypasses + // in the context). See TAB_PERMISSIONS above. + const visibleGroups = navGroups + .map((g) => ({ ...g, items: g.items.filter((i) => hasAnyPermission(TAB_PERMISSIONS[i.key] ?? ['settings.view'])) })) + .filter((g) => g.items.length > 0); + + const allItems = visibleGroups.flatMap((g) => g.items); const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0]; // (Visibility snap-back is handled in the useEffect above, which sits // before the isLoading early return to keep hook ordering stable.) @@ -345,7 +421,7 @@ export const SettingsPage: React.FC = () => { onChange={(e) => setActiveTab(e.target.value as TabType)} className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500" > - {navGroups.map((group) => ( + {visibleGroups.map((group) => ( {group.items.map((item) => (
)} diff --git a/frontend/src/pages/admin/event-details/OverviewTab.tsx b/frontend/src/pages/admin/event-details/OverviewTab.tsx index 43b5bba8..e6f11593 100644 --- a/frontend/src/pages/admin/event-details/OverviewTab.tsx +++ b/frontend/src/pages/admin/event-details/OverviewTab.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Event } from '../../../types'; import { FeedbackModerationPanel } from '../../../components/admin'; +import { PermissionGate } from '../../../components/admin/PermissionGate'; import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard'; import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard'; import { DownloadResolutionCard } from '../../../components/admin/DownloadResolutionCard'; @@ -158,15 +159,17 @@ export const OverviewTab: React.FC = ({ {/* Actions */} {!event.is_archived && ( - + + + )} diff --git a/frontend/src/pages/admin/event-details/PhotosTab.tsx b/frontend/src/pages/admin/event-details/PhotosTab.tsx index 6661e89a..0e8b7b92 100644 --- a/frontend/src/pages/admin/event-details/PhotosTab.tsx +++ b/frontend/src/pages/admin/event-details/PhotosTab.tsx @@ -6,6 +6,7 @@ import { Upload, X } from 'lucide-react'; import type { Event } from '../../../types'; import { Button, Card, Loading } from '../../../components/common'; import { AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PhotoUploadModal, PhotoFilterPanel, PhotoExportMenu } from '../../../components/admin'; +import { PermissionGate } from '../../../components/admin/PermissionGate'; import { externalMediaService } from '../../../services/externalMedia.service'; import { AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../../services/photos.service'; import { ExternalFolderPicker } from './ExternalFolderPicker'; @@ -93,29 +94,35 @@ export const PhotosTab: React.FC = ({ {/* Actions Bar */}
- - {event.source_mode === 'reference' && ( + + + {event.source_mode === 'reference' && ( + + + )}
- + + +
{/* Photo Grid */} diff --git a/frontend/src/services/roles.service.ts b/frontend/src/services/roles.service.ts new file mode 100644 index 00000000..448c2ddd --- /dev/null +++ b/frontend/src/services/roles.service.ts @@ -0,0 +1,76 @@ +import { api } from '../config/api'; + +/** + * Role-editor API client (backend: adminRoles.js). Lets an owner create custom + * roles, edit any role's permission set, clone a preset, and delete custom + * roles. Every mutation is gated by `roles.manage` on the backend. + */ + +export interface PermissionDef { + id: number; + name: string; + display_name: string; + category: string; + description?: string | null; +} + +export interface RoleWithPermissions { + id: number; + name: string; + displayName: string; + description?: string | null; + isSystem: boolean; + priority?: number; + userCount: number; + permissions: string[]; +} + +interface RolesResponse { roles: RoleWithPermissions[] } +interface RoleResponse { role: RoleWithPermissions } +interface PermissionsResponse { permissions: PermissionDef[] } + +export interface CreateRolePayload { + name: string; + displayName?: string; + description?: string | null; + priority?: number; + permissions?: string[]; +} + +export interface UpdateRolePayload { + displayName?: string; + description?: string | null; + priority?: number; + permissions?: string[]; +} + +export const rolesService = { + async getRoles(): Promise { + const res = await api.get('/admin/roles'); + return res.data.roles; + }, + + async getPermissionCatalog(): Promise { + const res = await api.get('/admin/roles/permissions'); + return res.data.permissions; + }, + + async createRole(payload: CreateRolePayload): Promise { + const res = await api.post('/admin/roles', payload); + return res.data.role; + }, + + async updateRole(id: number, payload: UpdateRolePayload): Promise { + const res = await api.put(`/admin/roles/${id}`, payload); + return res.data.role; + }, + + async cloneRole(id: number, payload: { name: string; displayName?: string; description?: string | null }): Promise { + const res = await api.post(`/admin/roles/${id}/clone`, payload); + return res.data.role; + }, + + async deleteRole(id: number): Promise { + await api.delete(`/admin/roles/${id}`); + }, +};