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:
co-authored by
Claude Sonnet 5
parent
43200d7dfe
commit
83ae3a08ff
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user