Lock pages against editor changes #42
@@ -435,6 +435,7 @@ body {
|
|||||||
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
||||||
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
|
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; } }
|
@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. */
|
/* Site scan progress bar and file count. */
|
||||||
|
|||||||
@@ -447,6 +447,7 @@
|
|||||||
const name = $('<td class="page-cell">').append(docIcon());
|
const name = $('<td class="page-cell">').append(docIcon());
|
||||||
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
|
$('<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.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)));
|
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);
|
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
|
||||||
if (permissions.manage && page.pending) {
|
if (permissions.manage && page.pending) {
|
||||||
@@ -456,6 +457,8 @@
|
|||||||
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
|
$('<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);
|
.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);
|
body.append(row);
|
||||||
});
|
});
|
||||||
@@ -638,6 +641,13 @@
|
|||||||
} catch (error) { showMessage(error.message, 'error'); }
|
} 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) {
|
async function discardNewPage(uri) {
|
||||||
if (!window.confirm('Discard this unpublished page and its draft? This cannot be undone.')) return;
|
if (!window.confirm('Discard this unpublished page and its draft? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ final class CMSController
|
|||||||
{
|
{
|
||||||
$this->requirePost('publish');
|
$this->requirePost('publish');
|
||||||
$uri = $this->requiredPost('uri');
|
$uri = $this->requiredPost('uri');
|
||||||
|
$this->assertNotLockedForEditor($uri);
|
||||||
$content = $this->requiredContentPost('content');
|
$content = $this->requiredContentPost('content');
|
||||||
$this->publishContent($uri, $content, 'Published page');
|
$this->publishContent($uri, $content, 'Published page');
|
||||||
$this->deleteDraft($uri);
|
$this->deleteDraft($uri);
|
||||||
@@ -150,6 +151,7 @@ final class CMSController
|
|||||||
{
|
{
|
||||||
$this->requirePost('draft');
|
$this->requirePost('draft');
|
||||||
$uri = $this->requiredPost('uri');
|
$uri = $this->requiredPost('uri');
|
||||||
|
$this->assertNotLockedForEditor($uri);
|
||||||
$content = $this->requiredContentPost('content');
|
$content = $this->requiredContentPost('content');
|
||||||
$this->storeDraft($uri, $content);
|
$this->storeDraft($uri, $content);
|
||||||
$this->activity('Saved draft', $uri);
|
$this->activity('Saved draft', $uri);
|
||||||
@@ -188,6 +190,7 @@ final class CMSController
|
|||||||
{
|
{
|
||||||
$this->requirePost('schedule');
|
$this->requirePost('schedule');
|
||||||
$uri = $this->requiredPost('uri');
|
$uri = $this->requiredPost('uri');
|
||||||
|
$this->assertNotLockedForEditor($uri);
|
||||||
$content = $this->requiredContentPost('content');
|
$content = $this->requiredContentPost('content');
|
||||||
$this->assertPublishable($uri);
|
$this->assertPublishable($uri);
|
||||||
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
|
$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']);
|
$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
|
* 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.
|
* 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]);
|
unset($marked[$sourceUri]);
|
||||||
return $marked;
|
return $marked;
|
||||||
});
|
});
|
||||||
|
$this->store->update('pagelocks', function (array $locked) use ($sourceUri) {
|
||||||
|
unset($locked[$sourceUri]);
|
||||||
|
return $locked;
|
||||||
|
});
|
||||||
$this->activity('Deleted page', $sourceUri);
|
$this->activity('Deleted page', $sourceUri);
|
||||||
$this->respond(['message' => 'Page deleted']);
|
$this->respond(['message' => 'Page deleted']);
|
||||||
return;
|
return;
|
||||||
@@ -482,6 +508,13 @@ final class CMSController
|
|||||||
}
|
}
|
||||||
return $marked;
|
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->activity(ucfirst($operation) . 'd page', $sourceUri . ' -> ' . $targetUri);
|
||||||
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
|
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
|
||||||
@@ -491,6 +524,7 @@ final class CMSController
|
|||||||
private function getPagesAction(): void
|
private function getPagesAction(): void
|
||||||
{
|
{
|
||||||
$drafts = $this->store->read('drafts');
|
$drafts = $this->store->read('drafts');
|
||||||
|
$locked = $this->store->read('pagelocks');
|
||||||
$pages = [];
|
$pages = [];
|
||||||
foreach ($this->paths->files() as $path) {
|
foreach ($this->paths->files() as $path) {
|
||||||
$uri = $this->paths->uriFor($path);
|
$uri = $this->paths->uriFor($path);
|
||||||
@@ -504,10 +538,11 @@ final class CMSController
|
|||||||
'title' => $this->extractTitle($html),
|
'title' => $this->extractTitle($html),
|
||||||
'modified' => date(DATE_ATOM, filemtime($path)),
|
'modified' => date(DATE_ATOM, filemtime($path)),
|
||||||
'draft' => isset($drafts[$uri]),
|
'draft' => isset($drafts[$uri]),
|
||||||
|
'locked' => isset($locked[$uri]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
foreach ($this->store->read('newpages') as $uri => $entry) {
|
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']));
|
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
|
||||||
$this->respond($pages);
|
$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. */
|
/** Return a required string POST field or raise a client-facing validation error. */
|
||||||
private function requiredPost(string $key): string
|
private function requiredPost(string $key): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `newpages.json` | Pending (unpublished) pages: title, chosen menu, template, author, created. |
|
||||||
| `pagetemplates.json` | Pages offered as templates in the New page section of Pages. |
|
| `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: {...}}`). |
|
| `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. |
|
| `media.json` | Per-file description (alt text or link text), original filename, and uploader. |
|
||||||
| `activity.json` | The latest 250 dashboard activity entries. |
|
| `activity.json` | The latest 250 dashboard activity entries. |
|
||||||
|
|||||||
@@ -219,10 +219,15 @@ try {
|
|||||||
|
|
||||||
// Administrator page operations must support duplicate, rename, delete, and restoration.
|
// Administrator page operations must support duplicate, rename, delete, and restoration.
|
||||||
$request('page', ['operation' => 'duplicate', 'uri' => '/index.html', 'target' => '/copy.html'], 'POST');
|
$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');
|
$request('page', ['operation' => 'rename', 'uri' => '/copy.html', 'target' => '/renamed.html'], 'POST');
|
||||||
assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed');
|
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');
|
$request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST');
|
||||||
assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed');
|
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');
|
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']);
|
$deletedRevisions = $request('revisions', ['uri' => '/renamed.html']);
|
||||||
$request('restoreRevision', ['id' => $deletedRevisions[0]['id']], 'POST');
|
$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}");
|
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.
|
// 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');
|
$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');
|
$err($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com']), 'Email changed without the current password');
|
||||||
|
|||||||
Reference in New Issue
Block a user