feat(dashboard): grants on the Permissions page and on account and character pages

A Grants tab on the Permissions page (with grants_manage: every grant in force, the history, and a form that asks
whose it is, searching accounts or characters by name), and a Permission grants card on account and character pages
(the account's or character's grants and history; the form with grants_manage). The form picks the kind (dashboard
permission, in-game command, every permission of a category, every command up to a GM level) and searches what to
grant among only what the signed-in user may grant; grant or deny, an optional expiry and a note. In-force grants have
a Remove button when the user may remove them. Players see their own grants, read-only. The Permissions page (and its
menu entry) now opens with permissions_manage or grants_manage; the GM level tabs still need permissions_manage.

Check: as GM 9, add a grant and a deny from the Permissions page and from an account and a character page, with and
without an expiry; remove one; the lists and history update (also in a second tab). As a GM 8 given grants_manage:
only the Grants tab shows, and only permissions and commands GM 8 has are offered. As a player: your own account page
lists your grants without a form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 01:07:38 -05:00
parent 821b7c8767
commit 868f34123f
6 changed files with 256 additions and 11 deletions

View File

@@ -470,7 +470,13 @@ void RegisterDashboardRoutes() {
SimplePage("/diagnostics", Perm("health_view"), "diagnostics.jinja2", "diagnostics", "Packets, bytes and HTTP requests per second of every server");
SimplePage("/players", Perm("players_view"), "players.jinja2", "players", "Who is online, with kick, rescue and teleport");
SimplePage("/backups", Perm("backups"), "backups.jinja2", "backups", "Database backups");
SimplePage("/permissions", Perm("permissions_manage"), "permissions.jinja2", "permissions", "What each GM level may do");
// The GM level tabs need permissions_manage, the grants tab grants_manage
Route(eHTTPMethod::GET, "/permissions", 0, "What each GM level may do, and permission grants", [](HTTPReply& reply, const HTTPContext& context) {
if (!Can(context, "permissions_manage") && !Can(context, "grants_manage")) {
return RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page.");
}
RenderPage(reply, context, "permissions.jinja2", "permissions");
});
SimplePage("/settings", Perm("settings"), "settings.jinja2", "settings", "Server settings");
SimplePage("/client_assets", Perm("client_files"), "client_assets.jinja2", "client_assets", "Browse the game client's files");
Route(eHTTPMethod::GET, "/about", 0, "About this server", [](HTTPReply& reply, const HTTPContext& context) {

View File

@@ -0,0 +1,175 @@
/**
* Permission grants: dashboard permissions and in-game commands given to (or taken from) one account or character.
* Grants.mount(el, {type, id}): the grants of one account or character, with a form to add one when the viewer may.
* Grants.mount(el, null): every grant in force and the history, with a form that asks whose it is (Permissions page).
* The server decides what may be granted (only what the viewer holds, on accounts they may manage); the form offers
* only those.
*/
(function () {
var KINDS = [
['permission', 'Dashboard permission'],
['command', 'In-game command'],
['permission_group', 'Every permission of a category'],
['command_group', 'Every command up to a GM level']
];
var catalog = null;
function loadCatalog() {
if (!catalog) {
catalog = api.get('/api/grants/catalog').then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not load what can be granted');
return d;
});
catalog.catch(function () { catalog = null; });
}
return catalog;
}
// What can be picked for a kind: [{value, label, detail}], only what the viewer may grant
function choices(d, kind, text) {
var list;
if (kind === 'permission') {
list = d.permissions.map(function (p) {
return { value: p.key, label: p.title + ' (' + p.key + ')', detail: p.category + ' · GM ' + p.level + '+ · ' + p.description, ok: p.grantable, why: p.reason };
});
} else if (kind === 'command') {
list = d.commands.map(function (c) {
return { value: c.name, label: '/' + c.aliases[0] + (c.aliases.length > 1 ? ' (also /' + c.aliases.slice(1).join(', /') + ')' : ''),
detail: 'GM ' + c.level + '+' + (c.permission ? ' · follows ' + c.permission : '') + ' · ' + c.help, ok: c.grantable, why: c.reason };
});
} else if (kind === 'permission_group') {
list = d.permissionGroups.map(function (g) {
return { value: g.name, label: g.name, detail: g.permissions.join(', '), ok: g.grantable, why: g.reason };
});
} else {
list = d.commandGroups.map(function (g) {
return { value: g.name, label: 'Every command up to GM ' + g.name, detail: '', ok: g.grantable, why: g.reason };
});
}
var query = (text || '').toLowerCase();
return list.filter(function (item) {
return item.ok && (!query || (item.label + ' ' + item.value + ' ' + item.detail).toLowerCase().indexOf(query) !== -1);
}).slice(0, 60);
}
function status(g) {
if (g.status === 'removed') return fmt.badge('Removed', 'secondary');
if (g.status === 'expired') return fmt.badge('Expired', 'secondary');
return fmt.badge('In force', 'success');
}
function targetLink(g) {
if (g.targetType === 'account') return 'Account ' + fmt.link('/accounts/' + g.targetId, g.targetName || g.targetId);
return 'Character ' + fmt.character(g.targetId, g.targetName);
}
function rows(list, options) {
if (!list.length) return '<tr><td colspan="6" class="text-body-secondary">' + esc(options.empty) + '</td></tr>';
return list.map(function (g) {
var when = 'By ' + esc(g.grantedBy || '?') + ', ' + fmt.unix(g.grantedAt) +
(g.revokedAt ? '<br>Removed by ' + esc(g.revokedBy) + ', ' + fmt.unix(g.revokedAt) : '');
return '<tr><td>' + (g.deny ? fmt.badge('Deny', 'danger') : fmt.badge('Grant', 'primary')) + '</td>' +
'<td><div class="fw-semibold">' + esc(g.label) + '</div>' + (options.showTarget ? '<div class="small">' + targetLink(g) + '</div>' : '') +
(g.note ? '<div class="small text-body-secondary">' + esc(g.note) + '</div>' : '') + '</td>' +
'<td class="small">' + (g.expiresAt ? fmt.unix(g.expiresAt) : 'Never') + '</td>' +
'<td class="small">' + when + '</td><td>' + status(g) + '</td>' +
'<td class="text-end">' + (g.canRemove ? '<button type="button" class="btn btn-sm btn-outline-danger" data-remove="' + g.id + '">Remove</button>' : '') + '</td></tr>';
}).join('');
}
function table(id, heading) {
return (heading ? '<h6 class="mt-3">' + heading + '</h6>' : '') +
'<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th></th><th>What</th><th>Expires</th><th>Given</th><th>Status</th><th></th></tr></thead>' +
'<tbody data-rows="' + id + '"></tbody></table></div>';
}
function form(chooseTarget) {
var target = chooseTarget ? '<div class="col-12 col-md-3"><label class="form-label small mb-1">For</label>' +
'<select class="form-select form-select-sm mb-1" data-target-type aria-label="Account or character"><option value="account">Account</option><option value="character">Character</option></select>' +
'<input type="search" class="form-control form-control-sm" data-target placeholder="Search by name" aria-label="Who"></div>' : '';
return '<form class="row g-2 align-items-end border rounded p-2 mb-3" data-grant-form>' + target +
'<div class="col-12 col-md-3"><label class="form-label small mb-1">Kind</label><select class="form-select form-select-sm" data-kind>' +
KINDS.map(function (k) { return '<option value="' + k[0] + '">' + esc(k[1]) + '</option>'; }).join('') + '</select></div>' +
'<div class="col-12 col-md-' + (chooseTarget ? '3' : '4') + '"><label class="form-label small mb-1">What</label><input type="search" class="form-control form-control-sm" data-name placeholder="Search" aria-label="What to grant"></div>' +
'<div class="col-6 col-md-2"><label class="form-label small mb-1">Effect</label><select class="form-select form-select-sm" data-deny><option value="">Grant</option><option value="1">Deny</option></select></div>' +
'<div class="col-6 col-md-' + (chooseTarget ? '3' : '3') + '"><label class="form-label small mb-1">Expires (empty: never)</label><input type="datetime-local" class="form-control form-control-sm" data-expires></div>' +
'<div class="col-12 col-md-' + (chooseTarget ? '6' : '9') + '"><input type="text" class="form-control form-control-sm" data-note maxlength="255" placeholder="Note (why)" aria-label="Note"></div>' +
'<div class="col-12 col-md-3"><button class="btn btn-sm btn-primary w-100" type="submit">Add</button></div>' +
'<div class="col-12 small text-body-secondary">Only what you have yourself can be picked. A deny takes it away even when the GM level allows it (never from GM 9).</div></form>';
}
function searchTargets(type, text) {
var url = type === 'account' ? '/api/tables/accounts' : '/api/tables/characters';
return api.post(url, { search: text, start: 0, length: 10 }).then(function (d) {
return (d.data || []).map(function (r) {
return { value: String(r.id), label: r.name, detail: type === 'account' ? 'Account ' + r.id + ' · GM ' + (r.gm_level || 0) : 'Character ' + r.id + (r.account_name ? ' · account ' + r.account_name : '') };
});
});
}
window.Grants = {
mount: function (el, target) {
var chooseTarget = !target;
el.innerHTML = '<div data-form></div>' + table('active', chooseTarget ? 'In force' : '') +
'<details class="mt-3"' + (chooseTarget ? ' open' : '') + '><summary class="small">History (removed and expired ones too)</summary>' + table('history', '') + '</details>';
var formHost = el.querySelector('[data-form]');
var nameSelect = null, targetSelect = null;
function load() {
var url = chooseTarget ? '/api/grants' : '/api/grants?' + target.type + '=' + encodeURIComponent(target.id);
return api.get(url).then(function (d) {
if (!d.success) {
el.querySelector('[data-rows="active"]').innerHTML = '<tr><td colspan="6" class="text-danger">' + esc(d.error || 'Could not load the grants') + '</td></tr>';
return;
}
var active = chooseTarget ? d.active : d.grants.filter(function (g) { return g.status === 'active'; });
var history = chooseTarget ? d.history : d.grants;
el.querySelector('[data-rows="active"]').innerHTML = rows(active, { showTarget: chooseTarget, empty: 'Nothing granted or denied.' });
el.querySelector('[data-rows="history"]').innerHTML = rows(history, { showTarget: chooseTarget, empty: 'No grants yet.' });
if ((chooseTarget || d.canManage) && !formHost.firstChild) setUpForm();
});
}
function setUpForm() {
formHost.innerHTML = form(chooseTarget);
var f = formHost.querySelector('form');
var kind = f.querySelector('[data-kind]');
nameSelect = SearchSelect(f.querySelector('[data-name]'), {
search: function (text) {
return loadCatalog().then(function (d) { return choices(d, kind.value, text); }).catch(function (e) { toast(e.message, 'danger'); return []; });
}
});
kind.addEventListener('change', function () { nameSelect.set('', ''); });
if (chooseTarget) {
var type = f.querySelector('[data-target-type]');
targetSelect = SearchSelect(f.querySelector('[data-target]'), { search: function (text) { return searchTargets(type.value, text); } });
type.addEventListener('change', function () { targetSelect.set('', ''); });
}
f.addEventListener('submit', function (e) {
e.preventDefault();
var who = chooseTarget ? { type: f.querySelector('[data-target-type]').value, id: targetSelect.input.dataset.value } : target;
var name = nameSelect.input.dataset.value;
if (!who.id) return toast('Pick an account or character', 'warning');
if (!name) return toast('Pick what to grant', 'warning');
var expires = f.querySelector('[data-expires]').value;
var body = { targetType: who.type, target: who.id, kind: kind.value, name: name, deny: f.querySelector('[data-deny]').value === '1',
expiresAt: expires ? Math.floor(new Date(expires).getTime() / 1000) : 0, note: f.querySelector('[data-note]').value };
api.action('/api/grants', body).then(function (r) {
toast(r.message, 'success');
nameSelect.set('', '');
f.querySelector('[data-note]').value = '';
load();
}).catch(function () {});
});
}
el.addEventListener('click', function (e) {
var button = e.target.closest('[data-remove]');
if (!button || !confirm('Remove this grant? It stops applying at once.')) return;
api.action('/api/grants/' + button.dataset.remove + '/remove', {}).then(function (r) { toast(r.message, 'success'); load(); }).catch(function () {});
});
if (window.Live) Live.on('grants', Live.throttle(load, 500));
load();
}
};
})();

View File

@@ -307,6 +307,16 @@
</div>
{% endif %}
{% if is_self or can.grants_manage %}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Permission grants</h5></div>
<div class="card-body">
<p class="small text-body-secondary">Dashboard permissions and in-game commands given to{% if can.grants_manage %} (or taken from){% endif %} this account on top of what its GM level allows. They count on the dashboard and in game, on every character; each character can have its own too (on its page).</p>
<div id="grantsPanel" data-account="{{ account.id }}"></div>
</div>
</div>
{% endif %}
{% if is_self or (can.api_keys_manage and can.accounts_view and manage.tools) %}
<div class="card mb-4" id="apiKeys" data-account="{{ account.id }}" data-self="{% if is_self %}1{% endif %}" data-can-create="{% if is_self and can.api_access %}1{% endif %}">
<div class="card-header d-flex align-items-center">
@@ -680,4 +690,11 @@ function disconnectMailAccount() {
<script src="/js/strikes.js"></script>
<script src="/js/account-links.js"></script>
<script src="/js/api-keys.js"></script>
<script src="/js/grants.js"></script>
<script>
(function () {
var panel = document.getElementById('grantsPanel');
if (panel) Grants.mount(panel, { type: 'account', id: panel.dataset.account });
})();
</script>
{% endblock %}

View File

@@ -245,6 +245,16 @@
{% endif %}
</div>
{% if can.grants_manage or character.is_own %}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Permission grants</h5></div>
<div class="card-body">
<p class="small text-body-secondary">In-game commands and permissions given to{% if can.grants_manage %} (or taken from){% endif %} this character on top of its GM level, while it is logged in. The account's own grants count too.</p>
<div id="grantsPanel" data-character="{{ character.id }}"></div>
</div>
</div>
{% endif %}
<div class="card mb-4" id="relatedCard" data-kind="character" data-id="{{ character.id }}"><div class="card-body text-body-secondary">Loading related data&hellip;</div></div>
</div>
{% endif %}
@@ -760,4 +770,11 @@ function rescueCharacter() {
<script src="/js/character-missions.js"></script>
<script src="/js/related.js"></script>
<script src="/js/mail-view.js"></script>
<script src="/js/grants.js"></script>
<script>
(function () {
var panel = document.getElementById('grantsPanel');
if (panel) Grants.mount(panel, { type: 'character', id: panel.dataset.character });
})();
</script>
{% endblock %}

View File

@@ -120,7 +120,7 @@
{% endif %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "webhooks", "client_assets", "ugc"] %}
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.webhooks or can.client_files or can.ugc_manage %}
{% if can.settings or can.permissions_manage or can.grants_manage or can.tasks_view or can.play_keys_manage or can.backups or can.webhooks or can.client_files or can.ugc_manage %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-admin" aria-expanded="{% if current_page in adminPages %}true{% else %}false{% endif %}" aria-controls="nav-admin">
Server Admin<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -128,7 +128,7 @@
<div class="collapse{% if current_page in adminPages %} show{% endif %}" id="nav-admin">
<div class="list-group list-group-flush">
{% if can.settings %}<a href="/settings" class="list-group-item list-group-item-action{% if current_page == "settings" or current_page == "settings_history" %} active{% endif %}">Settings</a>{% endif %}
{% if can.permissions_manage %}<a href="/permissions" class="list-group-item list-group-item-action{% if current_page == "permissions" %} active{% endif %}">Permissions</a>{% endif %}
{% if can.permissions_manage or can.grants_manage %}<a href="/permissions" class="list-group-item list-group-item-action{% if current_page == "permissions" %} active{% endif %}">Permissions</a>{% endif %}
{% if can.tasks_view %}<a href="/tasks" class="list-group-item list-group-item-action{% if current_page == "tasks" %} active{% endif %}">Scheduled Tasks</a>{% endif %}
{% if can.play_keys_manage %}<a href="/play_keys" class="list-group-item list-group-item-action{% if current_page == "play_keys" %} active{% endif %}">Play Keys</a>{% endif %}
{% if can.backups %}<a href="/backups" class="list-group-item list-group-item-action{% if current_page == "backups" %} active{% endif %}">Backups</a>{% endif %}

View File

@@ -23,16 +23,36 @@
<div class="mb-3">
<h2 class="mb-1">Permissions</h2>
<p class="text-body-secondary mb-0">What each GM level may do on the dashboard and in the game. Click a level to make it the lowest one allowed; every level
above it is allowed too. Changes apply straight away, no restart needed. GM 9 can always do everything on the dashboard.</p>
above it is allowed too. Changes apply straight away, no restart needed. GM 9 can always do everything on the dashboard.
<strong>Grants</strong> give (or take away) a permission or command for one account or character.</p>
</div>
<ul class="nav nav-tabs mb-3" id="permTabs" role="tablist">
{% if can.permissions_manage %}
<li class="nav-item" role="presentation"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabDashboard" data-hash="dashboard" type="button" role="tab">Dashboard <span class="badge rounded-pill text-bg-secondary" id="permCount"></span></button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabCommands" data-hash="commands" type="button" role="tab">In-game commands <span class="badge rounded-pill text-bg-secondary" id="commandCount"></span></button></li>
{% endif %}
{% if can.grants_manage %}
<li class="nav-item" role="presentation"><button class="nav-link{% if not can.permissions_manage %} active{% endif %}" data-bs-toggle="tab" data-bs-target="#tabGrants" data-hash="grants" type="button" role="tab">Grants</button></li>
{% endif %}
{% if can.permissions_manage %}
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabConfig" data-hash="config" type="button" role="tab">Config files</button></li>
{% endif %}
</ul>
<div class="tab-content">
{% if can.grants_manage %}
<div class="tab-pane fade{% if not can.permissions_manage %} show active{% endif %}" id="tabGrants" role="tabpanel">
<details class="about-text mb-2"><summary>How grants work</summary><p class="mb-0">A grant gives one account or character a dashboard permission, an
in-game command, every permission of a category or every command up to a GM level, on top of what its GM level allows. A deny takes one away
even when the GM level allows it (never from GM 9). A deny beats a grant. Account grants count on the dashboard and in game; character grants
count in game while that character is logged in. A grant of a permission also covers the commands that follow it. Online players get changes
at once. You can only grant what you have yourself, on accounts you may manage; <code>settings</code>, <code>permissions_manage</code>,
commands with a fixed level and commands that never go below a GM level (<code>/execute</code>) can't be granted.</p></details>
<div class="card"><div class="card-body" id="grantsPanel"></div></div>
</div>
{% endif %}
{% if can.permissions_manage %}
<div class="tab-pane fade show active" id="tabDashboard" role="tabpanel">
<div class="perm-toolbar d-flex flex-wrap align-items-center gap-2 mb-3">
<input type="search" class="form-control form-control-sm" id="permFilter" placeholder="Search permissions" aria-label="Search permissions">
@@ -98,12 +118,29 @@
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script src="/js/grants.js"></script>
<script>
(function () {
var grantsPanel = document.getElementById('grantsPanel');
if (grantsPanel) Grants.mount(grantsPanel, null);
// The open tab is in the address (#commands, #grants, #config), so it survives a reload and can be linked to
var tabs = document.querySelectorAll('#permTabs [data-hash]');
Array.prototype.forEach.call(tabs, function (tab) {
if (window.location.hash === '#' + tab.dataset.hash) bootstrap.Tab.getOrCreateInstance(tab).show();
tab.addEventListener('shown.bs.tab', function () { history.replaceState(null, '', '#' + tab.dataset.hash); });
});
})();
</script>
<script>
(function () {
// The GM level tabs: only with permissions_manage
if (!document.getElementById('permRows')) return;
var permissions = [], commands = [];
var SOURCES = { 'default': ['Default', 'secondary'], web: ['This page', 'primary'], file: ['Config file', 'info'], env: ['Environment', 'info'], fixed: ['Fixed', 'dark'], permission: ['Permission', 'success'] };
var filter = document.getElementById('commandFilter'), showClient = document.getElementById('showClient'), commandChanged = document.getElementById('commandChanged');
@@ -285,13 +322,6 @@
permCategory.addEventListener('change', renderPermissions);
permChanged.addEventListener('change', renderPermissions);
// The open tab is in the address (#commands, #config), so it survives a reload and can be linked to
var tabs = document.querySelectorAll('#permTabs [data-hash]');
Array.prototype.forEach.call(tabs, function (tab) {
if (window.location.hash === '#' + tab.dataset.hash) bootstrap.Tab.getOrCreateInstance(tab).show();
tab.addEventListener('shown.bs.tab', function () { history.replaceState(null, '', '#' + tab.dataset.hash); });
});
if (window.Live) Live.on('permissions', Live.throttle(load, 500));
load();
})();