feat: connect alpine api keys to admin api
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -185,7 +186,7 @@
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">deploy</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;">Publish draft operations, rollback deployments, and manage traffic splits.</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;">Publish agents and operations and perform deploy-scoped platform actions.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+164
-126
@@ -1,56 +1,55 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Crank — API Keys page
|
||||
Data: GET /api/workspaces/{slug}/keys
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
var KEYS = [];
|
||||
var currentWorkspaceSlug = '';
|
||||
|
||||
var SCOPES = ['read', 'write', 'deploy'];
|
||||
var SCOPE_DESC = {
|
||||
read: 'Read operations, logs, and usage',
|
||||
write: 'Create and update operations',
|
||||
deploy: 'Publish and rollback deployments',
|
||||
};
|
||||
|
||||
var currentWorkspaceId = null;
|
||||
var selectedScopes = new Set(['read']);
|
||||
var search = '';
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
var SCOPES = ['read', 'write', 'deploy'];
|
||||
var SCOPE_DESC = {
|
||||
read: 'Read operations, logs, and usage',
|
||||
write: 'Create and update operations',
|
||||
deploy: 'Publish agents and operations',
|
||||
};
|
||||
|
||||
function apiBase() {
|
||||
return '/api/workspaces/' + currentWorkspaceSlug + '/keys';
|
||||
function mapKeyRecord(record) {
|
||||
var apiKey = record.api_key || {};
|
||||
return {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
prefix: apiKey.prefix || '',
|
||||
scopes: apiKey.scopes || [],
|
||||
created: apiKey.created_at ? apiKey.created_at.slice(0, 10) : '—',
|
||||
lastUsed: apiKey.last_used_at ? apiKey.last_used_at.slice(0, 10) : 'Never',
|
||||
status: apiKey.status || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
// In production: replace fetch(mockUrl()) with fetch(apiBase())
|
||||
function mockUrl() {
|
||||
return (window.DATA_URL || 'data/') + 'keys.json';
|
||||
function currentWorkspace() {
|
||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
}
|
||||
|
||||
// ── Load keys for current workspace ─────────────────────────────────────────
|
||||
|
||||
function loadKeys() {
|
||||
currentWorkspaceSlug = getCurrentWs().slug;
|
||||
async function loadKeys() {
|
||||
var workspace = currentWorkspace();
|
||||
currentWorkspaceId = workspace ? workspace.id : null;
|
||||
|
||||
setTableLoading(true);
|
||||
|
||||
// Production: fetch(apiBase())
|
||||
fetch(mockUrl())
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
// Mock JSON is keyed by workspace slug; real API returns array directly
|
||||
KEYS = Array.isArray(data) ? data : (data[currentWorkspaceSlug] || []);
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
})
|
||||
.catch(function() {
|
||||
KEYS = [];
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
});
|
||||
if (!currentWorkspaceId || !window.CrankApi) {
|
||||
KEYS = [];
|
||||
setTableLoading(false);
|
||||
renderTable('Workspace or API is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listPlatformApiKeys(currentWorkspaceId);
|
||||
KEYS = ((response && response.items) || []).map(mapKeyRecord);
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
} catch (error) {
|
||||
KEYS = [];
|
||||
setTableLoading(false);
|
||||
renderTable(error.message || 'Failed to load API keys');
|
||||
}
|
||||
}
|
||||
|
||||
function setTableLoading(on) {
|
||||
@@ -66,54 +65,50 @@ function setTableLoading(on) {
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// ── Mutations (in production: real fetch calls) ──────────────────────────────
|
||||
|
||||
function revokeKey(id) {
|
||||
async function revokeKey(id) {
|
||||
if (!confirm('Revoke this key? All requests using it will fail immediately.')) return;
|
||||
|
||||
// Production: fetch(apiBase() + '/' + id, { method: 'PATCH', body: JSON.stringify({ status: 'revoked' }) })
|
||||
var k = KEYS.find(function(k) { return k.id === id; });
|
||||
if (k) { k.status = 'revoked'; renderTable(); }
|
||||
try {
|
||||
await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id);
|
||||
await loadKeys();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to revoke key');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteKey(id) {
|
||||
// Production: fetch(apiBase() + '/' + id, { method: 'DELETE' })
|
||||
KEYS.splice(KEYS.findIndex(function(k) { return k.id === id; }), 1);
|
||||
renderTable();
|
||||
async function deleteKey(id) {
|
||||
if (!confirm('Delete this key? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id);
|
||||
await loadKeys();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to delete key');
|
||||
}
|
||||
}
|
||||
|
||||
function createKey(name, scopes) {
|
||||
// Production: fetch(apiBase(), { method: 'POST', body: JSON.stringify({ name, scopes }) })
|
||||
// → response: { key: 'mcp_...', prefix: 'mcp_...', id: '...' }
|
||||
var rand = Array.from({length: 32}, function() { return Math.random().toString(36)[2]; }).join('');
|
||||
var rawKey = 'mcp_' + name.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4) + '_' + rand;
|
||||
var prefix = rawKey.slice(0, 12) + '…';
|
||||
var newKey = {
|
||||
id: 'key_' + Date.now(),
|
||||
name: name,
|
||||
prefix: prefix,
|
||||
scopes: scopes,
|
||||
created: new Date().toISOString().slice(0, 10),
|
||||
lastUsed: 'Never',
|
||||
status: 'active',
|
||||
async function createKey(name, scopes) {
|
||||
var created = await window.CrankApi.createPlatformApiKey(currentWorkspaceId, {
|
||||
name: name,
|
||||
scopes: scopes,
|
||||
});
|
||||
return {
|
||||
rawKey: created.secret,
|
||||
record: mapKeyRecord(created.api_key),
|
||||
};
|
||||
KEYS.unshift(newKey);
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderScopes() {
|
||||
var el = document.getElementById('scope-checkboxes');
|
||||
var el = document.getElementById('scope-checkboxes');
|
||||
var tmpl = document.getElementById('tmpl-scope-checkbox');
|
||||
el.innerHTML = '';
|
||||
SCOPES.forEach(function(s) {
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
SCOPES.forEach(function(scope) {
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
var input = node.querySelector('input');
|
||||
input.dataset.scope = s;
|
||||
if (selectedScopes.has(s)) input.checked = true;
|
||||
node.querySelector('.scope-checkbox-name').textContent = s;
|
||||
node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[s];
|
||||
input.dataset.scope = scope;
|
||||
if (selectedScopes.has(scope)) input.checked = true;
|
||||
node.querySelector('.scope-checkbox-name').textContent = scope;
|
||||
node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[scope];
|
||||
input.addEventListener('change', function() {
|
||||
if (this.checked) selectedScopes.add(this.dataset.scope);
|
||||
else selectedScopes.delete(this.dataset.scope);
|
||||
@@ -122,18 +117,36 @@ function renderScopes() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
var q = search.toLowerCase();
|
||||
function renderTable(errorMessage) {
|
||||
var q = search.toLowerCase();
|
||||
var tbody = document.getElementById('keys-tbody');
|
||||
var rows = KEYS.filter(function(k) {
|
||||
return !q || k.name.toLowerCase().includes(q) || k.prefix.includes(q);
|
||||
var rows = KEYS.filter(function(key) {
|
||||
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
|
||||
});
|
||||
var subtitle = document.querySelector('.section-card-subtitle');
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (subtitle) {
|
||||
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
|
||||
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
|
||||
subtitle.textContent = active + ' active · ' + revoked + ' revoked';
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
var errorRow = document.createElement('tr');
|
||||
var errorCell = document.createElement('td');
|
||||
errorCell.colSpan = 7;
|
||||
errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);';
|
||||
errorCell.textContent = errorMessage;
|
||||
errorRow.appendChild(errorCell);
|
||||
tbody.appendChild(errorRow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
var empty = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 7;
|
||||
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
td.textContent = KEYS.length ? 'No keys match your search' : 'No API keys yet';
|
||||
@@ -143,88 +156,107 @@ function renderTable() {
|
||||
}
|
||||
|
||||
var tmpl = document.getElementById('tmpl-key-row');
|
||||
rows.forEach(function(k) {
|
||||
rows.forEach(function(key) {
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
node.querySelector('.col-name').textContent = k.name;
|
||||
node.querySelector('.col-mono').textContent = k.prefix;
|
||||
node.querySelector('.col-created').textContent = k.created;
|
||||
node.querySelector('.col-last-used').textContent = k.lastUsed;
|
||||
node.querySelector('.col-name').textContent = key.name;
|
||||
node.querySelector('.col-mono').textContent = key.prefix;
|
||||
node.querySelector('.col-created').textContent = key.created;
|
||||
node.querySelector('.col-last-used').textContent = key.lastUsed;
|
||||
|
||||
var scopesEl = node.querySelector('.col-scopes');
|
||||
k.scopes.forEach(function(s) {
|
||||
key.scopes.forEach(function(scope) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'badge badge-scope';
|
||||
span.textContent = s;
|
||||
span.className = 'badge badge-scope';
|
||||
span.textContent = scope;
|
||||
scopesEl.appendChild(span);
|
||||
});
|
||||
|
||||
var badge = document.createElement('span');
|
||||
badge.className = k.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
|
||||
badge.textContent = k.status === 'active' ? 'Active' : 'Revoked';
|
||||
badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
|
||||
badge.textContent = key.status === 'active' ? 'Active' : 'Revoked';
|
||||
node.querySelector('.col-status').appendChild(badge);
|
||||
|
||||
var actionsActive = node.querySelector('.actions-active');
|
||||
var actionsActive = node.querySelector('.actions-active');
|
||||
var actionsRevoked = node.querySelector('.actions-revoked');
|
||||
if (k.status === 'active') {
|
||||
actionsActive.querySelector('[title="Copy key prefix"]')
|
||||
.addEventListener('click', function() { copyPrefix(k.prefix); });
|
||||
actionsActive.querySelector('[title="Revoke key"]')
|
||||
.addEventListener('click', function() { revokeKey(k.id); });
|
||||
if (key.status === 'active') {
|
||||
actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() {
|
||||
copyPrefix(key.prefix);
|
||||
});
|
||||
actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() {
|
||||
revokeKey(key.id);
|
||||
});
|
||||
} else {
|
||||
actionsActive.style.display = 'none';
|
||||
actionsActive.style.display = 'none';
|
||||
actionsRevoked.style.display = '';
|
||||
actionsRevoked.querySelector('[title="Delete"]')
|
||||
.addEventListener('click', function() { deleteKey(k.id); });
|
||||
actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() {
|
||||
deleteKey(key.id);
|
||||
});
|
||||
}
|
||||
|
||||
tbody.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────────────────
|
||||
|
||||
var modal = document.getElementById('modal-create');
|
||||
|
||||
function openModal() {
|
||||
document.getElementById('modal-form-body').style.display = '';
|
||||
document.getElementById('modal-reveal-body').style.display = 'none';
|
||||
document.getElementById('modal-form-body').style.display = '';
|
||||
document.getElementById('modal-reveal-body').style.display = 'none';
|
||||
document.getElementById('modal-footer-create').style.display = '';
|
||||
document.getElementById('modal-footer-done').style.display = 'none';
|
||||
document.getElementById('modal-footer-done').style.display = 'none';
|
||||
document.getElementById('new-key-name').value = '';
|
||||
selectedScopes = new Set(['read']);
|
||||
renderScopes();
|
||||
modal.classList.add('open');
|
||||
setTimeout(function() { document.getElementById('new-key-name').focus(); }, 50);
|
||||
setTimeout(function() {
|
||||
document.getElementById('new-key-name').focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function closeModal() { modal.classList.remove('open'); }
|
||||
function closeModal() {
|
||||
modal.classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('btn-create-key').addEventListener('click', openModal);
|
||||
document.getElementById('modal-close-btn').addEventListener('click', closeModal);
|
||||
document.getElementById('modal-cancel-btn').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
|
||||
modal.addEventListener('click', function(event) {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('modal-confirm-btn').addEventListener('click', function() {
|
||||
document.getElementById('modal-confirm-btn').addEventListener('click', async function() {
|
||||
var name = document.getElementById('new-key-name').value.trim();
|
||||
if (!name) { document.getElementById('new-key-name').focus(); return; }
|
||||
if (!selectedScopes.size) { alert('Select at least one scope.'); return; }
|
||||
if (!name) {
|
||||
document.getElementById('new-key-name').focus();
|
||||
return;
|
||||
}
|
||||
if (!selectedScopes.size) {
|
||||
alert('Select at least one scope.');
|
||||
return;
|
||||
}
|
||||
|
||||
var rawKey = createKey(name, Array.from(selectedScopes));
|
||||
|
||||
document.getElementById('reveal-key-value').textContent = rawKey;
|
||||
document.getElementById('modal-form-body').style.display = 'none';
|
||||
document.getElementById('modal-reveal-body').style.display = '';
|
||||
document.getElementById('modal-footer-create').style.display = 'none';
|
||||
document.getElementById('modal-footer-done').style.display = '';
|
||||
renderTable();
|
||||
try {
|
||||
var created = await createKey(name, Array.from(selectedScopes));
|
||||
KEYS.unshift(created.record);
|
||||
document.getElementById('reveal-key-value').textContent = created.rawKey;
|
||||
document.getElementById('modal-form-body').style.display = 'none';
|
||||
document.getElementById('modal-reveal-body').style.display = '';
|
||||
document.getElementById('modal-footer-create').style.display = 'none';
|
||||
document.getElementById('modal-footer-done').style.display = '';
|
||||
renderTable();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to create key');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('modal-done-btn').addEventListener('click', closeModal);
|
||||
|
||||
document.getElementById('copy-key-btn').addEventListener('click', function() {
|
||||
var val = document.getElementById('reveal-key-value').textContent;
|
||||
navigator.clipboard && navigator.clipboard.writeText(val);
|
||||
this.innerHTML = '<svg width="13" height="13"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>';
|
||||
var value = document.getElementById('reveal-key-value').textContent;
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(value).catch(function() {});
|
||||
}
|
||||
this.innerHTML = '<svg width="13" height="13"><use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"/></svg>';
|
||||
});
|
||||
|
||||
document.getElementById('key-search').addEventListener('input', function() {
|
||||
@@ -232,10 +264,16 @@ document.getElementById('key-search').addEventListener('input', function() {
|
||||
renderTable();
|
||||
});
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function copyPrefix(prefix) {
|
||||
navigator.clipboard && navigator.clipboard.writeText(prefix);
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(prefix).catch(function() {});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadKeys);
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
await loadKeys();
|
||||
window.addEventListener('crank:workspacechange', function() {
|
||||
loadKeys();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,5 +131,17 @@
|
||||
publishAgent: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
|
||||
},
|
||||
listPlatformApiKeys: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys');
|
||||
},
|
||||
createPlatformApiKey: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys', payload);
|
||||
},
|
||||
revokePlatformApiKey: function(workspaceId, keyId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
|
||||
},
|
||||
deletePlatformApiKey: function(workspaceId, keyId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||
},
|
||||
};
|
||||
}());
|
||||
|
||||
Reference in New Issue
Block a user