Lock pages against editor changes #42

Merged
GeoffMoore1979 merged 2 commits from feature/media-users-pages into master 2026-10-02 19:12:33 +02:00
5 changed files with 73 additions and 1 deletions
Showing only changes of commit 83ae3a08ff - Show all commits
+1
View File
@@ -435,6 +435,7 @@ body {
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
}
.badge-locked { background: #fde2e1; color: #8a1f1f; border-color: #f3b4b2; }
@media (max-width: 700px) { #fileList .page-title, #fileList th:nth-child(2), #fileList .page-date, #fileList th:nth-child(3) { display: none; } }
/* Site scan progress bar and file count. */
+10
View File
@@ -447,6 +447,7 @@
const name = $('<td class="page-cell">').append(docIcon());
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
if (page.draft) $('<span class="badge-draft">').text(page.pending ? 'New - unpublished' : 'Draft').appendTo(name);
if (page.locked) $('<span class="badge-draft badge-locked">').text('Locked').appendTo(name);
row.append(name, $('<td class="page-title">').text(page.title), $('<td class="page-date">').text(formatDate(page.modified)));
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
if (permissions.manage && page.pending) {
@@ -456,6 +457,8 @@
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
.on('click', function (event) { event.stopPropagation(); action[1] === 'template' ? setPageTemplate(page.url, true) : managePage(action[1], page.url); }).appendTo(actions);
});
$('<button type="button">').text(page.locked ? 'Unlock' : 'Lock')
.on('click', function (event) { event.stopPropagation(); lockPage(page.url, !page.locked); }).appendTo(actions);
}
body.append(row);
});
@@ -638,6 +641,13 @@
} catch (error) { showMessage(error.message, 'error'); }
}
async function lockPage(uri, on) {
try {
showMessage((await api('lockPage', {uri: uri, value: on ? '1' : '0'}, 'POST')).message, 'success');
openPages();
} catch (error) { showMessage(error.message, 'error'); }
}
async function discardNewPage(uri) {
if (!window.confirm('Discard this unpublished page and its draft? This cannot be undone.')) return;
try {
+47 -1
View File
@@ -139,6 +139,7 @@ final class CMSController
{
$this->requirePost('publish');
$uri = $this->requiredPost('uri');
$this->assertNotLockedForEditor($uri);
$content = $this->requiredContentPost('content');
$this->publishContent($uri, $content, 'Published page');
$this->deleteDraft($uri);
@@ -150,6 +151,7 @@ final class CMSController
{
$this->requirePost('draft');
$uri = $this->requiredPost('uri');
$this->assertNotLockedForEditor($uri);
$content = $this->requiredContentPost('content');
$this->storeDraft($uri, $content);
$this->activity('Saved draft', $uri);
@@ -188,6 +190,7 @@ final class CMSController
{
$this->requirePost('schedule');
$uri = $this->requiredPost('uri');
$this->assertNotLockedForEditor($uri);
$content = $this->requiredContentPost('content');
$this->assertPublishable($uri);
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
@@ -290,6 +293,25 @@ final class CMSController
$this->respond(['message' => $on ? 'Template created. It is now available in New Page.' : 'Page is no longer a template']);
}
/** Lock a page against editor changes, or unlock it; administrators only. */
private function lockPageAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
$this->paths->existing($uri);
$on = ($_POST['value'] ?? '') === '1';
$this->store->update('pagelocks', function (array $locked) use ($uri, $on) {
if ($on) {
$locked[$uri] = ['user' => $this->user(), 'created' => date(DATE_ATOM)];
} else {
unset($locked[$uri]);
}
return $locked;
});
$this->activity($on ? 'Locked page' : 'Unlocked page', $uri);
$this->respond(['message' => $on ? 'Page locked. Editors can no longer edit it.' : 'Page unlocked']);
}
/**
* Start a new page from a template. Nothing is written to the site: the page exists only as a private draft
* and a pending record until it is first published, so it is not visible on the front end before then.
@@ -458,6 +480,10 @@ final class CMSController
unset($marked[$sourceUri]);
return $marked;
});
$this->store->update('pagelocks', function (array $locked) use ($sourceUri) {
unset($locked[$sourceUri]);
return $locked;
});
$this->activity('Deleted page', $sourceUri);
$this->respond(['message' => 'Page deleted']);
return;
@@ -482,6 +508,13 @@ final class CMSController
}
return $marked;
});
$this->store->update('pagelocks', function (array $locked) use ($sourceUri, $targetUri) {
if (isset($locked[$sourceUri])) {
$locked[$targetUri] = $locked[$sourceUri];
unset($locked[$sourceUri]);
}
return $locked;
});
}
$this->activity(ucfirst($operation) . 'd page', $sourceUri . ' -> ' . $targetUri);
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
@@ -491,6 +524,7 @@ final class CMSController
private function getPagesAction(): void
{
$drafts = $this->store->read('drafts');
$locked = $this->store->read('pagelocks');
$pages = [];
foreach ($this->paths->files() as $path) {
$uri = $this->paths->uriFor($path);
@@ -504,10 +538,11 @@ final class CMSController
'title' => $this->extractTitle($html),
'modified' => date(DATE_ATOM, filemtime($path)),
'draft' => isset($drafts[$uri]),
'locked' => isset($locked[$uri]),
];
}
foreach ($this->store->read('newpages') as $uri => $entry) {
$pages[] = ['name' => $uri, 'url' => $uri, 'title' => (string) ($entry['title'] ?? ''), 'modified' => $entry['created'] ?? date(DATE_ATOM), 'draft' => true, 'pending' => true];
$pages[] = ['name' => $uri, 'url' => $uri, 'title' => (string) ($entry['title'] ?? ''), 'modified' => $entry['created'] ?? date(DATE_ATOM), 'draft' => true, 'pending' => true, 'locked' => isset($locked[$uri])];
}
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
$this->respond($pages);
@@ -1352,6 +1387,17 @@ final class CMSController
}
}
/** Refuse editors (never administrators) write access to a page an administrator has locked. */
private function assertNotLockedForEditor(string $uri): void
{
if ($this->authentication->can('manage')) {
return;
}
if (isset($this->store->read('pagelocks')[$this->paths->normaliseUri($uri)])) {
throw new \RuntimeException('This page is locked by an administrator and cannot be edited.');
}
}
/** Return a required string POST field or raise a client-facing validation error. */
private function requiredPost(string $key): string
{
+1
View File
@@ -58,6 +58,7 @@ Published content stays in the web document root. NeoCMS stores supporting data
| `menus.json` | Named menus: `{key: {items: [{label, url, parent}], updated, title?}}`; seeded by the site scan, never created by the CMS. |
| `newpages.json` | Pending (unpublished) pages: title, chosen menu, template, author, created. |
| `pagetemplates.json` | Pages offered as templates in the New page section of Pages. |
| `pagelocks.json` | Pages an administrator has locked against editor changes. |
| `users.json` | CMS-created accounts and profile overlays for config accounts (`{users: {...}, profiles: {...}}`). |
| `media.json` | Per-file description (alt text or link text), original filename, and uploader. |
| `activity.json` | The latest 250 dashboard activity entries. |
+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');