Files
sriphat-dataplatform/03-apiservice/app/templates/api_management.html
jigoong 76398c3de6 feat(apiservice): add edit client/key functionality in API Management page
- PATCH /admin/api-keys/clients/{id} — update client name and is_active
- PATCH /admin/api-keys/{id} — update key name and permissions
- Edit Client modal with name field and active/inactive toggle
- Edit Key modal with name field and permissions JSON textarea (pre-filled)
- Fix JS syntax error: use data-* attributes instead of inline JSON in onclick
2026-06-09 00:41:36 +07:00

621 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Management - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
h1 { color: #333; font-size: 32px; }
.user-info { display: flex; align-items: center; gap: 15px; }
.role-badge {
padding: 5px 15px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #667eea;
text-decoration: none;
font-weight: 600;
font-size: 16px;
}
.back-link:hover { text-decoration: underline; }
.alert {
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 8px;
font-weight: 500;
}
.alert-success { background: #51cf66; color: white; }
.alert-error { background: #ff6b6b; color: white; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-number { font-size: 36px; font-weight: bold; margin-bottom: 5px; }
.stat-label { font-size: 14px; opacity: 0.9; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 30px 0 15px;
}
.section-header h2 { font-size: 22px; color: #333; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #667eea; color: white; }
.btn-primary:hover { background: #5568d3; transform: translateY(-1px); }
.btn-success { background: #51cf66; color: white; }
.btn-success:hover { background: #40c057; transform: translateY(-1px); }
.btn-warning { background: #fcc419; color: #333; }
.btn-warning:hover { background: #fab005; transform: translateY(-1px); }
.btn-danger { background: #ff6b6b; color: white; }
.btn-danger:hover { background: #ee5a52; transform: translateY(-1px); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.client-card {
border: 1px solid #e0e0e0;
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
}
.client-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.client-name { font-size: 18px; font-weight: 600; color: #333; }
.client-meta { font-size: 13px; color: #666; margin-top: 3px; }
.client-actions { display: flex; gap: 8px; align-items: center; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { font-size: 12px; font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: 0.5px; background: #fafafa; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8f9ff; }
.status-active { color: #51cf66; font-weight: 600; }
.status-inactive { color: #ff6b6b; font-weight: 600; }
.key-prefix {
font-family: monospace;
background: #f1f3f5;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
}
.perm-tag {
display: inline-block;
background: #e7f5ff;
color: #1c7ed6;
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
margin: 2px 2px 2px 0;
}
.empty-keys {
padding: 20px;
text-align: center;
color: #aaa;
font-size: 14px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: white;
border-radius: 16px;
padding: 32px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal h3 { font-size: 20px; margin-bottom: 20px; color: #333; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 14px; font-weight: 600; color: #555; margin-bottom: 6px; }
.form-group input, .form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.form-group input:focus, .form-group textarea:focus { border-color: #667eea; }
.form-group small { font-size: 12px; color: #888; margin-top: 4px; display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
.key-result-box {
background: #f1f3f5;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px 16px;
font-family: monospace;
font-size: 14px;
word-break: break-all;
margin: 10px 0;
}
.key-warning {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: #856404;
margin-bottom: 12px;
}
.loading { text-align: center; padding: 40px; color: #666; }
.toggle-row { display: flex; align-items: center; gap: 10px; }
.toggle-switch {
position: relative; width: 44px; height: 24px; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; background: #ccc; border-radius: 24px; transition: 0.3s;
}
.toggle-slider:before {
content: ''; position: absolute; width: 18px; height: 18px;
left: 3px; top: 3px; background: white; border-radius: 50%; transition: 0.3s;
}
.toggle-switch input:checked + .toggle-slider { background: #51cf66; }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); }
</style>
</head>
<body>
<div class="container">
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
<div class="header">
<h1>🔑 API Management</h1>
{% if user %}
<div class="user-info">
<span>{{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge">{{ role }}</span>
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
<div id="alertContainer"></div>
<div class="stats">
<div class="stat-card">
<div class="stat-number" id="totalClients">-</div>
<div class="stat-label">API Clients</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalKeys">-</div>
<div class="stat-label">Total Keys</div>
</div>
<div class="stat-card">
<div class="stat-number" id="activeKeys">-</div>
<div class="stat-label">Active Keys</div>
</div>
</div>
<div class="section-header">
<h2>API Clients</h2>
<button class="btn btn-primary" onclick="openNewClientModal()">+ New Client</button>
</div>
<div id="clientsContainer">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Modal: New Client -->
<div class="modal-overlay" id="newClientModal">
<div class="modal">
<h3>New API Client</h3>
<div class="form-group">
<label>Client Name</label>
<input type="text" id="newClientName" placeholder="e.g. hospital-erp" />
<small>Unique identifier for this client system</small>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('newClientModal')">Cancel</button>
<button class="btn btn-primary" onclick="createClient()">Create</button>
</div>
</div>
</div>
<!-- Modal: New Key -->
<div class="modal-overlay" id="newKeyModal">
<div class="modal">
<h3>Generate API Key</h3>
<input type="hidden" id="newKeyClientId" />
<div class="form-group">
<label>Key Name (optional)</label>
<input type="text" id="newKeyName" placeholder="e.g. production" />
</div>
<div class="form-group">
<label>Permissions</label>
<textarea id="newKeyPermissions" rows="4" placeholder='["voc.data:write", "feed.checkpoint:write"]'></textarea>
<small>JSON array of permission strings</small>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('newKeyModal')">Cancel</button>
<button class="btn btn-success" onclick="generateKey()">Generate</button>
</div>
</div>
</div>
<!-- Modal: Edit Client -->
<div class="modal-overlay" id="editClientModal">
<div class="modal">
<h3>Edit API Client</h3>
<input type="hidden" id="editClientId" />
<div class="form-group">
<label>Client Name</label>
<input type="text" id="editClientName" />
</div>
<div class="form-group">
<label>Status</label>
<div class="toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="editClientActive" onchange="updateClientActiveLabel()" />
<span class="toggle-slider"></span>
</label>
<span id="editClientActiveLabel">Active</span>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('editClientModal')">Cancel</button>
<button class="btn btn-primary" onclick="saveEditClient()">Save</button>
</div>
</div>
</div>
<!-- Modal: Edit Key -->
<div class="modal-overlay" id="editKeyModal">
<div class="modal">
<h3>Edit API Key</h3>
<input type="hidden" id="editKeyId" />
<div class="form-group">
<label>Key Name (optional)</label>
<input type="text" id="editKeyName" placeholder="e.g. production" />
</div>
<div class="form-group">
<label>Permissions</label>
<textarea id="editKeyPermissions" rows="4" placeholder='["voc.data:write"]'></textarea>
<small>JSON array of permission strings</small>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('editKeyModal')">Cancel</button>
<button class="btn btn-primary" onclick="saveEditKey()">Save</button>
</div>
</div>
</div>
<!-- Modal: Show Key -->
<div class="modal-overlay" id="keyResultModal">
<div class="modal">
<h3>🔑 API Key Generated</h3>
<div class="key-warning">⚠️ Copy this key now — it will NOT be shown again after closing this dialog.</div>
<div class="form-group">
<label>API Key</label>
<div class="key-result-box" id="keyResultValue"></div>
<button class="btn btn-primary btn-sm" onclick="copyKey()" style="margin-top:8px;">Copy to Clipboard</button>
</div>
<div class="form-group">
<label>Permissions</label>
<div id="keyResultPerms"></div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeKeyResult()">Done</button>
</div>
</div>
</div>
<script>
const rootPath = "{{ root_path }}";
async function loadClients() {
try {
const res = await fetch(`${rootPath}/admin/api-keys/clients`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const clients = await res.json();
const totalKeys = clients.reduce((s, c) => s + c.api_keys.length, 0);
const activeKeys = clients.reduce((s, c) => s + c.api_keys.filter(k => k.is_active).length, 0);
document.getElementById('totalClients').textContent = clients.length;
document.getElementById('totalKeys').textContent = totalKeys;
document.getElementById('activeKeys').textContent = activeKeys;
const container = document.getElementById('clientsContainer');
if (clients.length === 0) {
container.innerHTML = '<div class="empty-keys">No API clients yet. Create one to get started.</div>';
return;
}
container.innerHTML = clients.map(client => `
<div class="client-card">
<div class="client-header">
<div>
<div class="client-name">${escapeHtml(client.name)}</div>
<div class="client-meta">ID: ${client.id} &nbsp;·&nbsp; ${client.api_keys.length} key(s)</div>
</div>
<div class="client-actions">
<span class="${client.is_active ? 'status-active' : 'status-inactive'}">${client.is_active ? '● Active' : '● Inactive'}</span>
<button class="btn btn-success btn-sm" onclick="openNewKeyModal(${client.id})">+ Add Key</button>
<button class="btn btn-warning btn-sm" data-id="${client.id}" data-name="${escapeHtml(client.name)}" data-active="${client.is_active}" onclick="openEditClientModal(this)">Edit</button>
</div>
</div>
${client.api_keys.length === 0
? '<div class="empty-keys">No API keys yet.</div>'
: `<table>
<thead><tr>
<th>ID</th><th>Name</th><th>Prefix</th><th>Permissions</th><th>Status</th><th>Created</th><th>Actions</th>
</tr></thead>
<tbody>
${client.api_keys.map(key => `
<tr>
<td>${key.id}</td>
<td>${key.name ? escapeHtml(key.name) : '-'}</td>
<td><span class="key-prefix">${escapeHtml(key.key_prefix)}...</span></td>
<td>${key.permissions.map(p => `<span class="perm-tag">${escapeHtml(p)}</span>`).join('') || '<span style="color:#aaa">none</span>'}</td>
<td class="${key.is_active ? 'status-active' : 'status-inactive'}">${key.is_active ? '● Active' : '● Inactive'}</td>
<td>${formatDate(key.created_at)}</td>
<td>
<button class="btn btn-primary btn-sm" data-id="${key.id}" data-name="${escapeHtml(key.name || '')}" data-perms="${JSON.stringify(key.permissions).replace(/"/g, '&quot;')}" onclick="openEditKeyModal(this)">Edit</button>
<button class="btn btn-warning btn-sm" onclick="regenerateKey(${key.id})">Regenerate</button>
<button class="btn btn-sm" style="background:#dee2e6" onclick="toggleKey(${key.id})">${key.is_active ? 'Deactivate' : 'Activate'}</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`
}
</div>
`).join('');
} catch (err) {
document.getElementById('clientsContainer').innerHTML = `<div class="empty-keys" style="color:#ff6b6b">Error: ${escapeHtml(err.message)}</div>`;
showAlert('Failed to load clients: ' + err.message, 'error');
}
}
function updateClientActiveLabel() {
const cb = document.getElementById('editClientActive');
document.getElementById('editClientActiveLabel').textContent = cb.checked ? 'Active' : 'Inactive';
}
function openEditClientModal(btn) {
const clientId = btn.dataset.id;
const name = btn.dataset.name;
const isActive = btn.dataset.active === 'true';
document.getElementById('editClientId').value = clientId;
document.getElementById('editClientName').value = name;
document.getElementById('editClientActive').checked = isActive;
document.getElementById('editClientActiveLabel').textContent = isActive ? 'Active' : 'Inactive';
document.getElementById('editClientModal').classList.add('active');
}
async function saveEditClient() {
const clientId = document.getElementById('editClientId').value;
const name = document.getElementById('editClientName').value.trim();
const isActive = document.getElementById('editClientActive').checked;
if (!name) return showAlert('Client name is required', 'error');
try {
const res = await fetch(`${rootPath}/admin/api-keys/clients/${clientId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, is_active: isActive })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
closeModal('editClientModal');
showAlert('Client updated', 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function openEditKeyModal(btn) {
const keyId = btn.dataset.id;
const name = btn.dataset.name;
let permissions = [];
try { permissions = JSON.parse(btn.dataset.perms); } catch {}
document.getElementById('editKeyId').value = keyId;
document.getElementById('editKeyName').value = name || '';
document.getElementById('editKeyPermissions').value = JSON.stringify(permissions, null, 2);
document.getElementById('editKeyModal').classList.add('active');
}
async function saveEditKey() {
const keyId = document.getElementById('editKeyId').value;
const name = document.getElementById('editKeyName').value.trim() || null;
const permsRaw = document.getElementById('editKeyPermissions').value.trim();
let permissions;
try { permissions = permsRaw ? JSON.parse(permsRaw) : []; }
catch { return showAlert('Permissions must be a valid JSON array', 'error'); }
try {
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, permissions })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
closeModal('editKeyModal');
showAlert('Key updated', 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function openNewClientModal() {
document.getElementById('newClientName').value = '';
document.getElementById('newClientModal').classList.add('active');
}
async function createClient() {
const name = document.getElementById('newClientName').value.trim();
if (!name) return showAlert('Client name is required', 'error');
try {
const res = await fetch(`${rootPath}/admin/api-keys/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
closeModal('newClientModal');
showAlert(`Client "${name}" created`, 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function openNewKeyModal(clientId) {
document.getElementById('newKeyClientId').value = clientId;
document.getElementById('newKeyName').value = '';
document.getElementById('newKeyPermissions').value = '';
document.getElementById('newKeyModal').classList.add('active');
}
async function generateKey() {
const clientId = parseInt(document.getElementById('newKeyClientId').value);
const name = document.getElementById('newKeyName').value.trim() || null;
const permsRaw = document.getElementById('newKeyPermissions').value.trim();
let permissions = [];
if (permsRaw) {
try { permissions = JSON.parse(permsRaw); }
catch { return showAlert('Permissions must be a valid JSON array', 'error'); }
}
try {
const res = await fetch(`${rootPath}/admin/api-keys/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId, name, permissions })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
const data = await res.json();
closeModal('newKeyModal');
showKeyResult(data.api_key, data.permissions);
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
async function regenerateKey(keyId) {
if (!confirm('Regenerate this key? The current key will stop working immediately.')) return;
try {
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}/regenerate`, { method: 'POST' });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
const data = await res.json();
showKeyResult(data.api_key, data.permissions);
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
async function toggleKey(keyId) {
try {
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}/toggle`, { method: 'PATCH' });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
const data = await res.json();
showAlert(`Key ${data.is_active ? 'activated' : 'deactivated'}`, 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function showKeyResult(apiKey, permissions) {
document.getElementById('keyResultValue').textContent = apiKey;
document.getElementById('keyResultPerms').innerHTML = permissions.length
? permissions.map(p => `<span class="perm-tag">${escapeHtml(p)}</span>`).join('')
: '<span style="color:#aaa">none</span>';
document.getElementById('keyResultModal').classList.add('active');
}
function copyKey() {
const key = document.getElementById('keyResultValue').textContent;
navigator.clipboard.writeText(key).then(() => showAlert('Copied to clipboard', 'success'));
}
function closeKeyResult() {
closeModal('keyResultModal');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
function showAlert(message, type) {
const el = document.getElementById('alertContainer');
el.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
setTimeout(() => el.innerHTML = '', 5000);
}
function formatDate(s) {
return new Date(s).toLocaleString('th-TH', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = String(text);
return d.innerHTML;
}
loadClients();
</script>
</body>
</html>