feat: lock pages against editor changes

Administrators can lock a page from the Pages list to stop editors
publishing, saving a draft, or scheduling a change to it. Administrators
are never restricted by a lock. State is stored per-page URI in the new
cms/data/pagelocks.json, following the existing pagetemplates.json
pattern: it moves with the page on rename, is cleared on delete, and is
never copied onto a duplicate.

- New lockPageAction and assertNotLockedForEditor() guard, called from
  save/saveDraft/schedule.
- getPages responses gain a locked flag; the Pages list gets a Lock/
  Unlock row action and a Locked badge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Geoff Moore
2026-10-02 12:11:06 +01:00
co-authored by Claude Sonnet 5
parent 43200d7dfe
commit 83ae3a08ff
5 changed files with 73 additions and 1 deletions
+14
View File
@@ -219,10 +219,15 @@ try {
// Administrator page operations must support duplicate, rename, delete, and restoration.
$request('page', ['operation' => 'duplicate', 'uri' => '/index.html', 'target' => '/copy.html'], 'POST');
assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/copy.html']), 'Duplicating a page copied its lock');
$request('lockPage', ['uri' => '/copy.html', 'value' => '1'], 'POST');
$request('page', ['operation' => 'rename', 'uri' => '/copy.html', 'target' => '/renamed.html'], 'POST');
assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed');
$locksAfterRename = (new NeoCMS\FileStore($data))->read('pagelocks');
assertTrue(!isset($locksAfterRename['/copy.html']) && isset($locksAfterRename['/renamed.html']), 'Renaming a page did not move its lock entry');
$request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST');
assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed');
assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/renamed.html']), 'Deleting a page left a stale lock entry');
assertTrue(in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Deleted page was not listed on the dashboard');
$deletedRevisions = $request('revisions', ['uri' => '/renamed.html']);
$request('restoreRevision', ['id' => $deletedRevisions[0]['id']], 'POST');
@@ -536,6 +541,15 @@ try {
assertTrue(($as('ed1', $action, $params)['error'] ?? '') === 'Your role cannot perform this action', "An editor could run {$action}");
}
// ---- Page locks: administrators toggle, only editors are blocked from editing ----
$ok($as('tester', 'lockPage', ['uri' => '/index.html', 'value' => '1']), 'Admin could not lock a page');
$pages = $as('tester', 'getPages', [], 'GET');
assertTrue((array_column($pages, 'locked', 'url'))['/index.html'] === true, 'Locked page was not reported as locked');
$err($as('ed1', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Editor saved a draft on a locked page');
$ok($as('tester', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Administrator was blocked by their own lock');
$ok($as('tester', 'lockPage', ['uri' => '/index.html', 'value' => '0']), 'Admin could not unlock a page');
$ok($as('ed1', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Editor still blocked after unlock');
// Self-service: display name freely, email and password need the current password.
$ok($as('ed1', 'saveProfile', ['name' => 'Edward Example', 'email' => 'ed@example.com']), 'Editor could not change their display name');
$err($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com']), 'Email changed without the current password');