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:
Aaron Kimbrell
2026-09-27 08:51:53 -05:00
parent 974a27329e
commit c29454dcd6
2 changed files with 212 additions and 28 deletions

View File

@@ -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 &lt;key&gt;</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 %}