From 868f34123f55b72e93b542e583fd2a551251449d Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 01:07:38 -0500 Subject: [PATCH] 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 --- dDashboardServer/routes/DashboardRoutes.cpp | 8 +- dDashboardServer/static/js/grants.js | 175 ++++++++++++++++++ .../templates/account-view.jinja2 | 17 ++ .../templates/character-view.jinja2 | 17 ++ dDashboardServer/templates/header.jinja2 | 4 +- dDashboardServer/templates/permissions.jinja2 | 46 ++++- 6 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 dDashboardServer/static/js/grants.js diff --git a/dDashboardServer/routes/DashboardRoutes.cpp b/dDashboardServer/routes/DashboardRoutes.cpp index 7e9c2597d..cb66967a4 100644 --- a/dDashboardServer/routes/DashboardRoutes.cpp +++ b/dDashboardServer/routes/DashboardRoutes.cpp @@ -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) { diff --git a/dDashboardServer/static/js/grants.js b/dDashboardServer/static/js/grants.js new file mode 100644 index 000000000..70274f012 --- /dev/null +++ b/dDashboardServer/static/js/grants.js @@ -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 '' + esc(options.empty) + ''; + return list.map(function (g) { + var when = 'By ' + esc(g.grantedBy || '?') + ', ' + fmt.unix(g.grantedAt) + + (g.revokedAt ? '
Removed by ' + esc(g.revokedBy) + ', ' + fmt.unix(g.revokedAt) : ''); + return '' + (g.deny ? fmt.badge('Deny', 'danger') : fmt.badge('Grant', 'primary')) + '' + + '
' + esc(g.label) + '
' + (options.showTarget ? '
' + targetLink(g) + '
' : '') + + (g.note ? '
' + esc(g.note) + '
' : '') + '' + + '' + (g.expiresAt ? fmt.unix(g.expiresAt) : 'Never') + '' + + '' + when + '' + status(g) + '' + + '' + (g.canRemove ? '' : '') + ''; + }).join(''); + } + + function table(id, heading) { + return (heading ? '
' + heading + '
' : '') + + '
' + + '
WhatExpiresGivenStatus
'; + } + + function form(chooseTarget) { + var target = chooseTarget ? '
' + + '' + + '
' : ''; + return '
' + target + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
Only what you have yourself can be picked. A deny takes it away even when the GM level allows it (never from GM 9).
'; + } + + 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 = '
' + table('active', chooseTarget ? 'In force' : '') + + '
History (removed and expired ones too)' + table('history', '') + '
'; + 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 = '' + esc(d.error || 'Could not load the grants') + ''; + 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(); + } + }; +})(); diff --git a/dDashboardServer/templates/account-view.jinja2 b/dDashboardServer/templates/account-view.jinja2 index 264591ee7..6027da824 100644 --- a/dDashboardServer/templates/account-view.jinja2 +++ b/dDashboardServer/templates/account-view.jinja2 @@ -307,6 +307,16 @@ {% endif %} +{% if is_self or can.grants_manage %} +
+
Permission grants
+
+

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).

+
+
+
+{% endif %} + {% if is_self or (can.api_keys_manage and can.accounts_view and manage.tools) %}
@@ -680,4 +690,11 @@ function disconnectMailAccount() { + + {% endblock %} diff --git a/dDashboardServer/templates/character-view.jinja2 b/dDashboardServer/templates/character-view.jinja2 index 50fe13be6..bde02f9ab 100644 --- a/dDashboardServer/templates/character-view.jinja2 +++ b/dDashboardServer/templates/character-view.jinja2 @@ -245,6 +245,16 @@ {% endif %}
+ {% if can.grants_manage or character.is_own %} +
+
Permission grants
+
+

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.

+
+
+
+ {% endif %} +
Loading related data…
{% endif %} @@ -760,4 +770,11 @@ function rescueCharacter() { + + {% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index d3c838828..e4f526a3d 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -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 %} {% endblock %} {% block scripts %} + +