mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): API keys section on the account page
Make keys with a permission picker grouped like the Permissions page (only the maker's own permissions), limits, restrictions and expiry; the list shows scope, lost permissions, limits, today's use, last use and requests, with rotate and revoke. Staff with api_keys_manage see and revoke other accounts' keys under the rank rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
163
dDashboardServer/static/js/api-keys.js
Normal file
163
dDashboardServer/static/js/api-keys.js
Normal file
@@ -0,0 +1,163 @@
|
||||
// API keys on the account page: list, make (with a permission picker of the maker's own permissions), rotate, revoke
|
||||
(function () {
|
||||
var card = document.getElementById('apiKeys');
|
||||
if (!card) return;
|
||||
var accountId = card.dataset.account;
|
||||
var isSelf = card.dataset.self === '1';
|
||||
var canCreate = card.dataset.canCreate === '1';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var catalog = null;
|
||||
var titles = {};
|
||||
|
||||
var STATUS = {
|
||||
active: ['Active', 'success'],
|
||||
revoked: ['Revoked', 'secondary'],
|
||||
expired: ['Expired', 'secondary'],
|
||||
signed_out: ['Stopped by sign out', 'warning']
|
||||
};
|
||||
|
||||
function loadCatalog() {
|
||||
if (catalog) return Promise.resolve(catalog);
|
||||
return api.get('/api/api_keys/permissions').then(function (d) {
|
||||
if (!d.success) throw new Error(d.error || 'Could not load permissions');
|
||||
catalog = d;
|
||||
d.permissions.forEach(function (p) { titles[p.key] = p.title; });
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
function scopeCell(k) {
|
||||
var html = k.allPermissions ? fmt.badge('All of ' + (isSelf ? 'your' : 'their') + ' permissions', 'primary')
|
||||
: '<span title="' + esc(k.permissions.map(function (p) { return titles[p] || p; }).join(', ')) + '">' + esc(k.permissions.length) + ' permission' + (k.permissions.length === 1 ? '' : 's') + '</span>';
|
||||
if (k.readOnly) html += ' ' + fmt.badge('read-only', 'info');
|
||||
if (k.lostPermissions.length) {
|
||||
html += '<div class="small text-warning" title="The owner can\'t do these any more, so the key can\'t either">Not working: ' + esc(k.lostPermissions.join(', ')) + '</div>';
|
||||
}
|
||||
if (k.allowedIps) html += '<div class="small text-body-secondary">From ' + esc(k.allowedIps) + '</div>';
|
||||
if (k.allowedPaths) html += '<div class="small text-body-secondary">Paths ' + esc(k.allowedPaths) + '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function limitsCell(k) {
|
||||
var html = esc(k.effectiveRateLimit) + '/min' + (k.rateLimit ? '' : ' <span class="small text-body-secondary">(default)</span>');
|
||||
if (k.dailyQuota) html += '<div class="small">' + esc(k.todayCount) + ' of ' + esc(k.dailyQuota) + ' today</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function row(k) {
|
||||
var status = STATUS[k.status] || [k.status, 'secondary'];
|
||||
var active = k.status === 'active' || k.status === 'signed_out';
|
||||
var actions = '';
|
||||
if (isSelf && k.status !== 'revoked' && k.status !== 'expired' && canCreate) actions += '<button class="btn btn-sm btn-outline-secondary me-1" data-rotate="' + esc(k.id) + '">Rotate</button>';
|
||||
if (k.status !== 'revoked') actions += '<button class="btn btn-sm btn-outline-danger" data-revoke="' + esc(k.id) + '">Revoke</button>';
|
||||
return '<tr' + (active ? '' : ' class="text-body-secondary"') + '>' +
|
||||
'<td><strong>' + esc(k.name) + '</strong> <code class="small">' + esc(k.prefix) + '…</code>' +
|
||||
(k.note ? '<div class="small text-body-secondary">' + esc(k.note) + '</div>' : '') +
|
||||
'<div class="small text-body-secondary">Made ' + fmt.unix(k.createdAt) + (k.createdBy ? ' by ' + esc(k.createdBy) : '') + '</div></td>' +
|
||||
'<td>' + scopeCell(k) + '</td>' +
|
||||
'<td>' + limitsCell(k) + '</td>' +
|
||||
'<td>' + (k.lastUsedAt ? fmt.unix(k.lastUsedAt) + '<div class="small text-body-secondary">' + esc(k.lastIp) + '</div>' : '<span class="text-body-secondary">Never</span>') + '</td>' +
|
||||
'<td>' + esc(k.requestCount) + '</td>' +
|
||||
'<td>' + (k.expiresAt ? fmt.unix(k.expiresAt) : 'Never') + '</td>' +
|
||||
'<td>' + fmt.badge(status[0], status[1]) + (k.revokedAt ? '<div class="small text-body-secondary">' + fmt.unix(k.revokedAt) + (k.revokedBy ? ' by ' + esc(k.revokedBy) : '') + '</div>' : '') + '</td>' +
|
||||
'<td class="text-nowrap text-end">' + actions + '</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
return loadCatalog().catch(function () { return null; }).then(function () {
|
||||
return api.get('/api/accounts/' + accountId + '/api_keys');
|
||||
}).then(function (d) {
|
||||
var rows = $('apiKeyRows');
|
||||
if (!d.success) { rows.innerHTML = '<tr><td colspan="8" class="text-danger">' + esc(d.error || 'Could not load the keys') + '</td></tr>'; return; }
|
||||
rows.innerHTML = d.keys.length ? d.keys.map(row).join('') : '<tr><td colspan="8" class="text-body-secondary">No API keys.</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// The maker's own permissions, grouped by category like the Permissions page
|
||||
function renderPicker() {
|
||||
var groups = {};
|
||||
var order = [];
|
||||
catalog.permissions.forEach(function (p) {
|
||||
if (!p.allowed) return;
|
||||
if (!groups[p.category]) { groups[p.category] = []; order.push(p.category); }
|
||||
groups[p.category].push(p);
|
||||
});
|
||||
$('apiKeyPicker').innerHTML = order.map(function (category, i) {
|
||||
return '<fieldset class="mb-2"><legend class="fs-6 fw-semibold mb-1">' + esc(category) +
|
||||
' <button type="button" class="btn btn-link btn-sm p-0 ms-1 align-baseline" data-group="' + i + '">all</button></legend><div class="row g-1">' +
|
||||
groups[category].map(function (p) {
|
||||
var id = 'apiKeyPerm_' + p.key;
|
||||
return '<div class="col-md-6 col-xl-4"><div class="form-check" title="' + esc(p.description) + '">' +
|
||||
'<input class="form-check-input" type="checkbox" value="' + esc(p.key) + '" id="' + id + '" data-group-of="' + i + '">' +
|
||||
'<label class="form-check-label" for="' + id + '">' + esc(p.title) + ' <span class="text-body-secondary">(' + esc(p.key) + ')</span></label></div></div>';
|
||||
}).join('') + '</div></fieldset>';
|
||||
}).join('') || '<p class="text-body-secondary">You have no permissions to give a key.</p>';
|
||||
$('apiKeyRate').placeholder = 'Default (' + catalog.defaultRateLimit + ')';
|
||||
$('apiKeyRate').max = catalog.maxRateLimit;
|
||||
}
|
||||
|
||||
function showForm(show) {
|
||||
$('apiKeyForm').classList.toggle('d-none', !show);
|
||||
if (show) loadCatalog().then(renderPicker).catch(function (e) { toast(e.message, 'danger'); });
|
||||
}
|
||||
|
||||
function showSecret(key) {
|
||||
$('apiKeySecret').value = key;
|
||||
$('apiKeyCreated').classList.remove('d-none');
|
||||
$('apiKeySecret').select();
|
||||
}
|
||||
|
||||
if ($('apiKeyNew')) $('apiKeyNew').addEventListener('click', function () { showForm(true); });
|
||||
$('apiKeyCancel').addEventListener('click', function () { showForm(false); });
|
||||
$('apiKeyCopy').addEventListener('click', function () {
|
||||
var box = $('apiKeySecret');
|
||||
box.select();
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(box.value).then(function () { toast('Copied', 'success'); });
|
||||
});
|
||||
$('apiKeyScopeAll').addEventListener('change', function () { $('apiKeyPicker').classList.add('d-none'); });
|
||||
$('apiKeyScopeSome').addEventListener('change', function () { $('apiKeyPicker').classList.remove('d-none'); });
|
||||
$('apiKeyPicker').addEventListener('click', function (e) {
|
||||
var group = e.target.dataset.group;
|
||||
if (group === undefined) return;
|
||||
var boxes = $('apiKeyPicker').querySelectorAll('[data-group-of="' + group + '"]');
|
||||
var check = Array.prototype.some.call(boxes, function (b) { return !b.checked; });
|
||||
boxes.forEach(function (b) { b.checked = check; });
|
||||
});
|
||||
|
||||
$('apiKeyForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var all = $('apiKeyScopeAll').checked;
|
||||
var picked = Array.prototype.map.call($('apiKeyPicker').querySelectorAll('input:checked'), function (b) { return b.value; });
|
||||
if (!all && !picked.length) { toast('Pick at least one permission, or all of yours', 'warning'); return; }
|
||||
api.action('/api/api_keys', {
|
||||
name: $('apiKeyName').value,
|
||||
note: $('apiKeyNote').value,
|
||||
permissions: all ? '*' : picked,
|
||||
readOnly: $('apiKeyReadOnly').checked,
|
||||
allowedIps: $('apiKeyIps').value,
|
||||
allowedPaths: $('apiKeyPaths').value,
|
||||
rateLimit: parseInt($('apiKeyRate').value, 10) || 0,
|
||||
dailyQuota: parseInt($('apiKeyQuota').value, 10) || 0,
|
||||
expiresInDays: parseInt($('apiKeyExpiry').value, 10) || 0
|
||||
}).then(function (d) {
|
||||
$('apiKeyForm').reset();
|
||||
$('apiKeyPicker').classList.remove('d-none');
|
||||
showForm(false);
|
||||
showSecret(d.key);
|
||||
load();
|
||||
}).catch(function () {});
|
||||
});
|
||||
|
||||
$('apiKeyRows').addEventListener('click', function (e) {
|
||||
var rotate = e.target.dataset.rotate, revoke = e.target.dataset.revoke;
|
||||
if (rotate) {
|
||||
if (!confirm('Make a new secret for this key? The current one stops working at once.')) return;
|
||||
api.action('/api/api_keys/' + rotate + '/rotate', {}).then(function (d) { showSecret(d.key); load(); }).catch(function () {});
|
||||
} else if (revoke) {
|
||||
if (!confirm('Revoke this key? Anything using it stops working at once.')) return;
|
||||
api.action('/api/api_keys/' + revoke + '/revoke', {}, 'API key revoked').then(load).catch(function () {});
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -297,26 +297,9 @@
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h5 class="mb-0">API Token</h5></div>
|
||||
<div class="card-header"><h5 class="mb-0">Sessions</h5></div>
|
||||
<div class="card-body">
|
||||
{% if can.api_access %}
|
||||
<p class="small text-body-secondary">A bearer token for scripts and bots. It acts as you, with your current GM level. See <a href="/api_docs">API docs</a>.</p>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text">Valid for</span>
|
||||
<select id="tokenDays" class="form-select">
|
||||
<option value="7">7 days</option>
|
||||
<option value="30" selected>30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-primary" onclick="createToken()">Generate</button>
|
||||
</div>
|
||||
<textarea id="apiToken" class="form-control form-control-sm font-monospace d-none" rows="3" readonly></textarea>
|
||||
{% else %}
|
||||
<p class="small text-body-secondary">API access isn't turned on for your account.</p>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<p class="small text-body-secondary mb-2">Lost a token, or signed in on a computer that isn't yours? This signs out every other browser and stops every API token you made.</p>
|
||||
<p class="small text-body-secondary mb-2">Signed in on a computer that isn't yours, or lost an API key? This signs out every other browser and stops every API key you made (rotate a key to start it again).</p>
|
||||
<button class="btn btn-outline-danger btn-sm" id="revokeOwnSessions">Sign out everywhere else</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,6 +307,52 @@
|
||||
</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">
|
||||
<h5 class="mb-0 me-auto">API Keys</h5>
|
||||
{% if is_self and can.api_access %}<button class="btn btn-sm btn-primary" id="apiKeyNew">New key</button>{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if is_self %}
|
||||
<p class="small text-body-secondary">Keys for scripts and bots (<code>Authorization: Bearer <key></code>, see the <a href="/api_docs">API docs</a>). A key does only what you pick for it, and never more than you can do right now: if your GM level or permissions change, your keys change with them.{% if not can.api_access %} API access isn't turned on for your account, so you can't make keys.{% endif %}</p>
|
||||
{% endif %}
|
||||
<div id="apiKeyCreated" class="alert alert-success d-none">
|
||||
<div class="mb-1"><strong>Copy this key now.</strong> It won't be shown again.</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control font-monospace" id="apiKeySecret" readonly>
|
||||
<button class="btn btn-outline-secondary" type="button" id="apiKeyCopy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="apiKeyForm" class="border rounded p-3 mb-3 d-none">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label small mb-0" for="apiKeyName">Name</label><input class="form-control form-control-sm" id="apiKeyName" maxlength="32" required placeholder="Discord bot"></div>
|
||||
<div class="col-md-8"><label class="form-label small mb-0" for="apiKeyNote">Note</label><input class="form-control form-control-sm" id="apiKeyNote" maxlength="255" placeholder="What it's for (optional)"></div>
|
||||
<div class="col-6 col-md-3"><label class="form-label small mb-0" for="apiKeyExpiry">Expires</label>
|
||||
<select class="form-select form-select-sm" id="apiKeyExpiry"><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90" selected>In 90 days</option><option value="365">In a year</option><option value="0">Never</option></select></div>
|
||||
<div class="col-6 col-md-3"><label class="form-label small mb-0" for="apiKeyRate">Requests a minute</label><input type="number" min="0" class="form-control form-control-sm" id="apiKeyRate" placeholder="Default"></div>
|
||||
<div class="col-6 col-md-3"><label class="form-label small mb-0" for="apiKeyQuota">Requests a day</label><input type="number" min="0" class="form-control form-control-sm" id="apiKeyQuota" placeholder="No limit"></div>
|
||||
<div class="col-6 col-md-3 d-flex align-items-end"><div class="form-check"><input class="form-check-input" type="checkbox" id="apiKeyReadOnly"><label class="form-check-label small" for="apiKeyReadOnly">Read-only (no changes)</label></div></div>
|
||||
<div class="col-md-6"><label class="form-label small mb-0" for="apiKeyIps">Only from addresses</label><input class="form-control form-control-sm font-monospace" id="apiKeyIps" placeholder="Any; e.g. 203.0.113.7, 10.0.0."></div>
|
||||
<div class="col-md-6"><label class="form-label small mb-0" for="apiKeyPaths">Only these paths</label><input class="form-control form-control-sm font-monospace" id="apiKeyPaths" placeholder="Any; e.g. /api/chat, /api/players"></div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="radio" name="apiKeyScope" id="apiKeyScopeSome" value="some" checked><label class="form-check-label" for="apiKeyScopeSome">Only the permissions I pick</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="radio" name="apiKeyScope" id="apiKeyScopeAll" value="all"><label class="form-check-label" for="apiKeyScopeAll">All of my permissions</label></div>
|
||||
</div>
|
||||
<div id="apiKeyPicker" class="mt-2 small"></div>
|
||||
<div class="mt-3"><button class="btn btn-sm btn-primary" type="submit">Make key</button> <button class="btn btn-sm btn-outline-secondary" type="button" id="apiKeyCancel">Cancel</button></div>
|
||||
</form>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead><tr><th>Name</th><th>Can do</th><th>Limits</th><th>Last used</th><th>Requests</th><th>Expires</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody id="apiKeyRows"><tr><td colspan="8" class="text-body-secondary">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal fade" id="gmLevelModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -646,17 +675,9 @@ function disconnectMailAccount() {
|
||||
if (!confirm('Disconnect the mail account? Emails will stop sending until one is connected again.')) return;
|
||||
api.action('/api/email/oauth2/disconnect', {}).then(function (d) { return d.result; }).then(loadEmailStatus).catch(function () {});
|
||||
}
|
||||
function createToken() {
|
||||
var days = parseInt(document.getElementById('tokenDays').value, 10);
|
||||
api.action('/api/auth/token', { days: days }, 'Token generated - copy it now, it will not be shown again').then(function (d) {
|
||||
var box = document.getElementById('apiToken');
|
||||
box.value = d.token;
|
||||
box.classList.remove('d-none');
|
||||
box.select();
|
||||
}).catch(function () {});
|
||||
}
|
||||
</script>
|
||||
<script src="/js/related.js"></script>
|
||||
<script src="/js/strikes.js"></script>
|
||||
<script src="/js/account-links.js"></script>
|
||||
<script src="/js/api-keys.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user