diff --git a/dDashboardServer/static/js/api-keys.js b/dDashboardServer/static/js/api-keys.js new file mode 100644 index 000000000..2fa685296 --- /dev/null +++ b/dDashboardServer/static/js/api-keys.js @@ -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') + : '' + esc(k.permissions.length) + ' permission' + (k.permissions.length === 1 ? '' : 's') + ''; + if (k.readOnly) html += ' ' + fmt.badge('read-only', 'info'); + if (k.lostPermissions.length) { + html += '
Not working: ' + esc(k.lostPermissions.join(', ')) + '
'; + } + if (k.allowedIps) html += '
From ' + esc(k.allowedIps) + '
'; + if (k.allowedPaths) html += '
Paths ' + esc(k.allowedPaths) + '
'; + return html; + } + + function limitsCell(k) { + var html = esc(k.effectiveRateLimit) + '/min' + (k.rateLimit ? '' : ' (default)'); + if (k.dailyQuota) html += '
' + esc(k.todayCount) + ' of ' + esc(k.dailyQuota) + ' today
'; + 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 += ''; + if (k.status !== 'revoked') actions += ''; + return '' + + '' + esc(k.name) + ' ' + esc(k.prefix) + '…' + + (k.note ? '
' + esc(k.note) + '
' : '') + + '
Made ' + fmt.unix(k.createdAt) + (k.createdBy ? ' by ' + esc(k.createdBy) : '') + '
' + + '' + scopeCell(k) + '' + + '' + limitsCell(k) + '' + + '' + (k.lastUsedAt ? fmt.unix(k.lastUsedAt) + '
' + esc(k.lastIp) + '
' : 'Never') + '' + + '' + esc(k.requestCount) + '' + + '' + (k.expiresAt ? fmt.unix(k.expiresAt) : 'Never') + '' + + '' + fmt.badge(status[0], status[1]) + (k.revokedAt ? '
' + fmt.unix(k.revokedAt) + (k.revokedBy ? ' by ' + esc(k.revokedBy) : '') + '
' : '') + '' + + '' + actions + ''; + } + + 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 = '' + esc(d.error || 'Could not load the keys') + ''; return; } + rows.innerHTML = d.keys.length ? d.keys.map(row).join('') : 'No API keys.'; + }); + } + + // 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 '
' + esc(category) + + '
' + + groups[category].map(function (p) { + var id = 'apiKeyPerm_' + p.key; + return '
' + + '' + + '
'; + }).join('') + '
'; + }).join('') || '

You have no permissions to give a key.

'; + $('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(); +})(); diff --git a/dDashboardServer/templates/account-view.jinja2 b/dDashboardServer/templates/account-view.jinja2 index 52caa9f69..264591ee7 100644 --- a/dDashboardServer/templates/account-view.jinja2 +++ b/dDashboardServer/templates/account-view.jinja2 @@ -297,26 +297,9 @@
-
API Token
+
Sessions
- {% if can.api_access %} -

A bearer token for scripts and bots. It acts as you, with your current GM level. See API docs.

-
- Valid for - - -
- - {% else %} -

API access isn't turned on for your account.

- {% endif %} -
-

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.

+

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

@@ -324,6 +307,52 @@
{% endif %} +{% if is_self or (can.api_keys_manage and can.accounts_view and manage.tools) %} +
+
+
API Keys
+ {% if is_self and can.api_access %}{% endif %} +
+
+ {% if is_self %} +

Keys for scripts and bots (Authorization: Bearer <key>, see the API docs). 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 %}

+ {% endif %} +
+
Copy this key now. It won't be shown again.
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
NameCan doLimitsLast usedRequestsExpiresStatus
Loading...
+
+
+
+{% endif %} +