feat(permissions): granular permission gating + role editor & presets (#747, phase 1 of #743) (#1045)

* feat(permissions): granular permission gating + role editor & presets

Make every admin feature permission-gateable so multi-user studios can
split capability across roles (#747, and phase 1 of #743).

- Split the catch-all settings.edit into dedicated dangerous-config perms
  (banking / domains / security / integrations / features): a team member
  can no longer change IBAN, domains, SSO, webhooks, API tokens or feature
  flags. Reads keep an OR with settings.view so existing roles keep
  visibility. The site-URL write inside /general is change-gated on
  settings.domains.
- Add dedicated perms for admin surfaces miscategorised under settings.*
  (whatsapp, event_types, image_security, notifications, system) plus
  roles.manage and vat_codes.view; gate the previously-ungated VAT read.
- Boot self-heal (_permissionsBoot.js): super_admin always holds every
  permission (tracks-all) so new perms never need a compensation
  migration; all other roles stay frozen (no silent escalation on upgrade).
- Seed two presets: Solo Photographer (full operator) and Team
  Photographer (contributor — view events + manage photos + read-only CRM;
  no settings/users/billing edits, no events.edit).
- Role editor: adminRoles CRUD (create/edit/clone/delete + permission
  matrix; system roles protected, super_admin immutable) and a Roles tab
  with a category-grouped matrix and preset cloning.
- Settings page tabs are permission-gated with snap-back; i18n en/de.

Migration 174. Backward-compatible: admin/editor/viewer unchanged.

* feat(permissions): hide in-page action buttons a role can't use

Wrap mutating controls on the surfaces restricted roles actually reach
(Events list, Archives, gallery photo grid, event detail) in
PermissionGate so they are HIDDEN when the user lacks the permission,
rather than shown-then-403:

- Events list: create / bulk archive / bulk delete / row archive /
  row delete / download-archive.
- Archives: restore / download / delete.
- Photo grid: single + bulk delete (photos.delete), per-photo download
  (photos.download), bulk move/hide/show (photos.edit).
- Event detail: edit / rename / publish (events.edit), duplicate
  (events.create), archive (events.archive), create-invoice
  (bills.manage); the Actions card is hidden entirely for view-only roles.
- Photos tab: upload / external import (photos.upload), export menu
  (photos.download).

Backend already enforces these with 403; this is the matching UX so a
Team Photographer never sees delete/settings controls.

* fix(permissions): close settings-split bypass via generic settings writers

Security review found the settings.edit split was bypassable: the generic
settings writers (/general, /analytics, /seo, /security) upsert arbitrary
setting_keys, so a role holding only settings.edit (or settings.security)
could write keys owned by a narrower permission — repointing the public
site URL (settings.domains), security policy (settings.security) or
VAT/accounting config (settings.banking) via the wrong endpoint.

Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop
any protected key the caller isn't permitted to write (general_site_url →
settings.domains, security_* → settings.security, accounting_* →
settings.banking). Dedicated routes still work because their caller holds
the matching perm. Replaces the narrower in-handler site-URL guard.

Also fix two tests affected by the RBAC changes:
- authzPermissionGaps: API-token management moved to settings.integrations,
  so grant that (not settings.edit) to exercise the ownership 404.
- AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated
  and the test renders without a PermissionsProvider).

* fix(permissions): address upstream review (#1045)

- Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates
  from #1035; the collision made picpeakImportService's forward-only restore
  guard treat both as order 174 and accept a newer .picpeak onto an older schema).
- Contain the roles.manage blast radius (delegation, not root escalation): a
  non-super_admin can no longer edit their own role, nor grant any permission
  their own role doesn't already hold (createRole + updateRole).
- Protected-key denial now 403s (naming the keys + required perms) instead of
  silently stripping and reporting "saved" (adminSettings generic writers).
- Reserve team_photographer so a custom role can't squat the preset name.
- Boot self-heal: per-step try/catch so a role_permissions insert race on one
  replica doesn't skip preset seeding.
- Forward-project the feature .manage perms that also replaced settings.edit
  gates (whatsapp/event_types/image_security/notifications/system), matching the
  settings.* split projection so the pattern is symmetric for phase-2.
- Guard exports.down's roles/admin_users queries with hasTable.

* fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045)

Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
  general_site_url on every save, so a settings.edit-only role (the office
  manager this PR enables) got 403'd on every General save even when the URL
  was unchanged. Restore change-detection: compare the incoming value against
  the stored one and 403 only on an actual change; unchanged protected keys are
  dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
  adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
  its own role, can't escalate another role; super_admin bypasses;
  team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
  unchanged general_site_url saves, an actual change 403s, super_admin changes it.
This commit is contained in:
Luca
2026-08-16 14:59:52 +02:00
committed by GitHub
parent 9b976386f9
commit b118695474
35 changed files with 2215 additions and 331 deletions
@@ -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: '[email protected]', 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);
});
});
@@ -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: '[email protected]', 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');
});
});
@@ -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();
@@ -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;
+12
View File
@@ -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
+3 -3
View File
@@ -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();
+9 -9
View File
@@ -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);
+6 -6
View File
@@ -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) => {
+2 -2
View File
@@ -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)) {
+8 -8
View File
@@ -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;
+4 -4
View File
@@ -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 });
+125
View File
@@ -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;
+76 -7
View File
@@ -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'),
+12 -12
View File
@@ -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);
+5 -5
View File
@@ -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' });
+14 -1
View File
@@ -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;
+13 -9
View File
@@ -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')
+3 -3
View File
@@ -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) {
+148
View File
@@ -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 };
+265 -1
View File
@@ -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 (249 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
@@ -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,6 +210,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<PermissionGate permission="photos.edit">
<Button
variant="outline"
size="sm"
@@ -245,6 +247,8 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
{t('admin.photos.showSelected', 'Show')}
</Button>
</PermissionGate>
<PermissionGate permission="photos.delete">
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
@@ -253,6 +257,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<Trash2 className="w-4 h-4" />
{t('gallery.deleteSelected', 'Delete Selected')}
</button>
</PermissionGate>
</>
)}
</>
@@ -420,12 +425,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{!isSelectionMode && (
<div className="flex gap-1">
<PermissionGate permission="photos.download">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
>
<Download className="w-3 h-3" />
</button>
</PermissionGate>
<PermissionGate permission="photos.delete">
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded disabled:opacity-50"
@@ -433,6 +441,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
<Trash2 className="w-3 h-3" />
</button>
</PermissionGate>
</div>
)}
</div>
@@ -666,6 +675,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
{!isSelectionMode && (
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<PermissionGate permission="photos.download">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-600 rounded"
@@ -673,6 +683,8 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
<Download className="w-4 h-4" />
</button>
</PermissionGate>
<PermissionGate permission="photos.delete">
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-50"
@@ -681,6 +693,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
<Trash2 className="w-4 h-4" />
</button>
</PermissionGate>
</div>
)}
</td>
@@ -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<string, string> = {
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<RoleEditorModalProps> = ({
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<Set<string>>(new Set());
const [nameError, setNameError] = useState<string | undefined>();
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<string, PermissionDef[]>();
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 (249 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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-3xl max-h-[90vh] flex flex-col">
<div className="p-6 flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-accent" />
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{mode === 'create'
? t('roleEditor.createTitle', 'Create role')
: t('roleEditor.editTitle', 'Edit role: {{name}}', { name: role?.displayName })}
</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button>
</div>
{readOnly && (
<div className="mb-4 flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800">
<Lock className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-300">
{t('roleEditor.superAdminLocked', 'Super Admin always holds every permission and cannot be edited. It automatically gains new permissions as features are added.')}
</p>
</div>
)}
{/* Identity fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-2">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('roleEditor.displayName', 'Display name')}
</label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('roleEditor.displayNamePlaceholder', 'e.g. Photographer')}
disabled={isLoading || readOnly}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('roleEditor.key', 'Key (identifier)')}
</label>
<Input
value={name}
onChange={(e) => { setName(e.target.value); setNameError(undefined); }}
placeholder="photographer"
disabled={isLoading || mode === 'edit'}
/>
{nameError && <p className="mt-1 text-sm text-red-600">{nameError}</p>}
{mode === 'edit' && (
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
{t('roleEditor.keyLocked', 'The key is fixed once a role is created.')}
</p>
)}
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('roleEditor.description', 'Description')}
</label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('roleEditor.descriptionPlaceholder', 'What this role is for')}
disabled={isLoading || readOnly}
/>
</div>
{/* Permission matrix */}
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
{t('roleEditor.permissions', 'Permissions')}
</h3>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('roleEditor.selectedCount', '{{count}} selected', { count: selected.size })}
</span>
</div>
<div className="space-y-3">
{grouped.map(([category, perms]) => {
const selectedInCat = perms.filter((p) => selected.has(p.name)).length;
const allSelected = selectedInCat === perms.length;
return (
<div key={category} className="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">{categoryLabel(category)}</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500">{selectedInCat}/{perms.length}</span>
</div>
<button
type="button"
onClick={() => toggleCategory(perms, allSelected)}
disabled={readOnly}
className="text-xs font-medium text-accent hover:underline disabled:opacity-40 disabled:no-underline"
>
{allSelected ? t('roleEditor.clearAll', 'Clear all') : t('roleEditor.selectAll', 'Select all')}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4">
{perms.map((p) => {
const checked = selected.has(p.name);
return (
<label
key={p.name}
className={`flex items-start gap-2 px-3 py-2 border-t border-neutral-100 dark:border-neutral-700/60 ${readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-700/40'}`}
title={p.description || undefined}
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePerm(p.name)}
disabled={readOnly}
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-accent"
/>
<span className="min-w-0">
<span className="block text-sm text-neutral-800 dark:text-neutral-200">{p.display_name}</span>
<span className="block text-[11px] text-neutral-400 dark:text-neutral-500 font-mono truncate">{p.name}</span>
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div className="flex justify-end gap-3 p-4 border-t border-neutral-200 dark:border-neutral-700">
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
{t('common.cancel')}
</Button>
{!readOnly && (
<Button
type="button"
variant="primary"
onClick={handleSave}
isLoading={isLoading}
leftIcon={<Save className="w-4 h-4" />}
>
{mode === 'create' ? t('roleEditor.create', 'Create role') : t('common.save', 'Save')}
</Button>
)}
</div>
</Card>
</div>
);
};
RoleEditorModal.displayName = 'RoleEditorModal';
@@ -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<RoleWithPermissions | null>(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 (
<div className="flex items-center justify-center min-h-[300px]">
<Loading size="lg" text={t('roleEditor.loading', 'Loading roles…')} />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('roleEditor.subtitle', 'Define what each role can do. Start from a preset by cloning it, then trim or extend the permissions.')}
</p>
<Button
variant="primary"
leftIcon={<Plus className="w-4 h-4" />}
onClick={() => setEditor({ mode: 'create', role: null })}
>
{t('roleEditor.newRole', 'New role')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{(roles || []).map((role) => {
const isSuperAdmin = role.name === 'super_admin';
return (
<Card key={role.id} padding="sm" className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(role.name)}`}>
<Shield className="w-3 h-3" />
{role.displayName}
</span>
{role.isSystem && (
<span className="inline-flex items-center gap-1 text-[11px] text-neutral-400 dark:text-neutral-500">
<Lock className="w-3 h-3" />
{t('roleEditor.systemRole', 'System')}
</span>
)}
</div>
<p className="mt-1 text-xs font-mono text-neutral-400 dark:text-neutral-500">{role.name}</p>
{role.description && (
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 line-clamp-2">{role.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-4 mt-3 text-xs text-neutral-500 dark:text-neutral-400">
<span>{t('roleEditor.permCount', '{{count}} permissions', { count: role.permissions.length })}</span>
<span className="flex items-center gap-1">
<UsersIcon className="w-3.5 h-3.5" />
{t('roleEditor.userCount', '{{count}} users', { count: role.userCount })}
</span>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-700">
<Button
variant="outline"
size="sm"
leftIcon={<Edit className="w-3.5 h-3.5" />}
onClick={() => setEditor({ mode: 'edit', role })}
>
{isSuperAdmin ? t('roleEditor.view', 'View') : t('common.edit', 'Edit')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Copy className="w-3.5 h-3.5" />}
onClick={() => setEditor({ mode: 'create', role: { ...role, displayName: `${role.displayName} copy` } })}
>
{t('roleEditor.clone', 'Clone')}
</Button>
{!role.isSystem && (
<Button
variant="outline"
size="sm"
leftIcon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => setDeleteTarget(role)}
className="text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
>
{t('common.delete', 'Delete')}
</Button>
)}
</div>
</Card>
);
})}
</div>
{editor && (
<RoleEditorModal
isOpen
mode={editor.mode}
role={editor.role}
catalog={catalog || []}
isLoading={createMutation.isPending || updateMutation.isPending}
onClose={() => setEditor(null)}
onSave={handleSave}
/>
)}
{deleteTarget && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900/40">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('roleEditor.confirmDelete.title', 'Delete role?')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{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 })}
</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleteMutation.isPending}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
isLoading={deleteMutation.isPending}
disabled={deleteTarget.userCount > 0}
className="bg-red-600 hover:bg-red-700 focus:ring-red-500"
>
{t('common.delete', 'Delete')}
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
RoleManagementTab.displayName = 'RoleManagementTab';
@@ -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 } }
+37 -1
View File
@@ -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 (249 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."
}
}
}
+37 -1
View File
@@ -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 (249 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."
}
}
}
@@ -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,6 +303,7 @@ export const ArchivesPage: React.FC = () => {
Details
</Button>
*/}
<PermissionGate permission="archives.download">
<Button
variant="ghost"
size="sm"
@@ -311,6 +313,8 @@ export const ArchivesPage: React.FC = () => {
>
{t('archives.download')}
</Button>
</PermissionGate>
<PermissionGate permission="archives.restore">
<Button
variant="ghost"
size="sm"
@@ -320,6 +324,8 @@ export const ArchivesPage: React.FC = () => {
>
{t('archives.restore')}
</Button>
</PermissionGate>
<PermissionGate permission="archives.delete">
<Button
variant="ghost"
size="sm"
@@ -330,6 +336,7 @@ export const ArchivesPage: React.FC = () => {
>
{t('archives.delete')}
</Button>
</PermissionGate>
</div>
</td>
</tr>
@@ -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,6 +315,7 @@ export const EventsListPage: React.FC = () => {
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.title')}</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('events.subtitle')}</p>
</div>
<PermissionGate permission="events.create">
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
@@ -321,6 +323,7 @@ export const EventsListPage: React.FC = () => {
>
{t('events.createEvent')}
</Button>
</PermissionGate>
</div>
{/* Statistics Cards fed from /admin/dashboard/stats so the totals
@@ -442,6 +445,7 @@ export const EventsListPage: React.FC = () => {
<Button variant="outline" size="sm" onClick={() => setSelectedEvents([])}>
{t('events.clear')}
</Button>
<PermissionGate permission="events.archive">
<Button
variant="outline"
size="sm"
@@ -449,6 +453,8 @@ export const EventsListPage: React.FC = () => {
>
{t('events.archiveSelected')}
</Button>
</PermissionGate>
<PermissionGate permission="events.delete">
<Button
variant="outline"
size="sm"
@@ -457,6 +463,7 @@ export const EventsListPage: React.FC = () => {
>
{t('events.deleteSelected', 'Delete Selected')}
</Button>
</PermissionGate>
</div>
</div>
)}
@@ -665,6 +672,7 @@ export const EventsListPage: React.FC = () => {
</button>
) : null}
{!event.is_archived ? (
<PermissionGate permission="events.archive">
<button
onClick={() => {
archiveMutation.mutate(event.id);
@@ -676,8 +684,10 @@ export const EventsListPage: React.FC = () => {
<Archive className="w-4 h-4" />
{t('events.archiveEventAction')}
</button>
</PermissionGate>
) : null}
{event.is_archived ? (
<PermissionGate permission="archives.download">
<button
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
@@ -689,7 +699,9 @@ export const EventsListPage: React.FC = () => {
<Download className="w-4 h-4" />
{t('events.downloadArchiveAction')}
</button>
</PermissionGate>
) : null}
<PermissionGate permission="events.delete">
<button
onClick={() => {
if (confirm(t('events.deleteEventConfirm'))) {
@@ -703,6 +715,7 @@ export const EventsListPage: React.FC = () => {
<Trash2 className="w-4 h-4" />
{t('events.deleteEvent')}
</button>
</PermissionGate>
</div>
</div>
)}
+79 -3
View File
@@ -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<TabType, string[]> = {
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<Record<TabType, boolean>> = {
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 (
<div className="flex items-center justify-center min-h-[400px]">
@@ -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) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((item) => (
<option key={item.key} value={item.key}>
@@ -363,7 +439,7 @@ export const SettingsPage: React.FC = () => {
aria-label={t('settings.navAriaLabel', 'Settings navigation')}
className="sticky top-6 space-y-6"
>
{navGroups.map((group) => (
{visibleGroups.map((group) => (
<div key={group.label}>
<h3 className="px-3 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{group.label}
@@ -23,8 +23,10 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { userManagementService } from '../../services/userManagement.service';
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
import { useLocalizedDate, useModal, useMutationWithToast } from "../../hooks";
import { usePermissions } from '../../contexts/PermissionsContext';
import { RoleManagementTab } from '../../components/admin/RoleManagementTab';
type TabType = 'users' | 'invitations';
type TabType = 'users' | 'invitations' | 'roles';
// Role badge colors
const getRoleBadgeColor = (roleName: string): string => {
@@ -369,6 +371,8 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
export const UserManagementPage: React.FC = () => {
const { t } = useTranslation();
const { formatDistanceToNow } = useLocalizedDate()
const { hasAnyPermission } = usePermissions();
const canManageRoles = hasAnyPermission(['roles.manage', 'users.view']);
// State
const [activeTab, setActiveTab] = useState<TabType>('users');
@@ -612,6 +616,9 @@ export const UserManagementPage: React.FC = () => {
label: t('userManagement.tabs.invitations'),
count: invitations?.length || 0,
},
...(canManageRoles
? [{ key: 'roles' as TabType, label: t('userManagement.tabs.roles', 'Roles'), count: roles?.length || 0 }]
: []),
];
return (
@@ -721,6 +728,7 @@ export const UserManagementPage: React.FC = () => {
</div>
{/* Search */}
{activeTab !== 'roles' && (
<Card padding="sm" className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
@@ -738,6 +746,7 @@ export const UserManagementPage: React.FC = () => {
</div>
</div>
</Card>
)}
{/* Users Tab Content */}
{activeTab === 'users' && (
@@ -967,6 +976,9 @@ export const UserManagementPage: React.FC = () => {
</Card>
)}
{/* Roles Tab Content */}
{activeTab === 'roles' && canManageRoles && <RoleManagementTab />}
{/* Create Invitation Modal */}
<CreateInvitationModal
isOpen={createInvitationModal.isOpen}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Archive, Send, Copy } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { PermissionGate } from '../../../components/admin/PermissionGate';
interface EventActionsCardProps {
event: Event;
@@ -31,7 +32,7 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
<div className="space-y-3">
{event.is_draft ? (
<>
<PermissionGate permission="events.edit">
<Button
variant="primary"
leftIcon={<Send className="w-4 h-4" />}
@@ -44,9 +45,9 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.draftBanner')}
</p>
</>
</PermissionGate>
) : (
<>
<PermissionGate permission="events.archive">
<Button
variant="outline"
leftIcon={<Archive className="w-4 h-4" />}
@@ -63,10 +64,11 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.archivingInfo')}
</p>
</>
</PermissionGate>
)}
{/* Duplicate (#626) visible in both draft and live mode.
Creates a new draft inheriting this gallery's config. */}
<PermissionGate permission="events.create">
<Button
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
@@ -76,6 +78,7 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
>
{t('events.duplicateEvent', 'Duplicate gallery')}
</Button>
</PermissionGate>
</div>
</Card>
);
@@ -17,6 +17,7 @@ import {
} from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { PermissionGate } from '../../../components/admin/PermissionGate';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { buildShareLinkUrl } from '../../../utils/url';
@@ -137,6 +138,7 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
</>
) : (
<>
<PermissionGate permission="events.edit">
<Button
variant="outline"
size="sm"
@@ -153,6 +155,7 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
>
{t('events.rename.button', 'Rename')}
</Button>
</PermissionGate>
{feedbackSettings?.feedback_enabled && (
<Button
variant="outline"
@@ -167,6 +170,7 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
bill editor with the event snapshot + (when exactly
one is linked) the customer. Gated on the bills flag. */}
{flags.bills && (
<PermissionGate permission="bills.manage">
<Button
variant="outline"
size="sm"
@@ -182,6 +186,7 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
>
{t('events.createInvoice', 'Create invoice')}
</Button>
</PermissionGate>
)}
</>
)}
@@ -222,6 +227,7 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
{t('events.draftBanner')}
</p>
</div>
<PermissionGate permission="events.edit">
<Button
variant="primary"
size="sm"
@@ -231,6 +237,7 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
>
{t('events.publishAndNotify')}
</Button>
</PermissionGate>
</div>
</Card>
)}
@@ -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,6 +159,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
{/* Actions */}
{!event.is_archived && (
<PermissionGate permissions={['events.edit', 'events.archive', 'events.create']}>
<EventActionsCard
event={event}
onArchive={onArchive}
@@ -167,6 +169,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
setShowDuplicateDialog={setShowDuplicateDialog}
isDuplicating={isDuplicating}
/>
</PermissionGate>
)}
</div>
@@ -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,6 +94,7 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
{/* Actions Bar */}
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
<div className="flex items-center gap-3">
<PermissionGate permission="photos.upload">
<Button
variant="primary"
size="sm"
@@ -101,7 +103,9 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
>
{t('events.uploadPhotos')}
</Button>
</PermissionGate>
{event.source_mode === 'reference' && (
<PermissionGate permission="photos.upload">
<Button
variant="outline"
size="sm"
@@ -109,13 +113,16 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
>
{t('events.importExternal', 'Import from External Folder')}
</Button>
</PermissionGate>
)}
</div>
<PermissionGate permission="photos.download">
<PhotoExportMenu
eventId={parseInt(id!)}
selectedPhotoIds={selectedPhotoIds}
filters={feedbackFilters}
/>
</PermissionGate>
</div>
{/* Photo Grid */}
+76
View File
@@ -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<RoleWithPermissions[]> {
const res = await api.get<RolesResponse>('/admin/roles');
return res.data.roles;
},
async getPermissionCatalog(): Promise<PermissionDef[]> {
const res = await api.get<PermissionsResponse>('/admin/roles/permissions');
return res.data.permissions;
},
async createRole(payload: CreateRolePayload): Promise<RoleWithPermissions> {
const res = await api.post<RoleResponse>('/admin/roles', payload);
return res.data.role;
},
async updateRole(id: number, payload: UpdateRolePayload): Promise<RoleWithPermissions> {
const res = await api.put<RoleResponse>(`/admin/roles/${id}`, payload);
return res.data.role;
},
async cloneRole(id: number, payload: { name: string; displayName?: string; description?: string | null }): Promise<RoleWithPermissions> {
const res = await api.post<RoleResponse>(`/admin/roles/${id}/clone`, payload);
return res.data.role;
},
async deleteRole(id: number): Promise<void> {
await api.delete(`/admin/roles/${id}`);
},
};