mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
feat(web): add Users admin tab to web UI
Adds a Users tab to the web gateway UI for managing users, tokens, and roles without needing direct API calls. Features: - User list table with ID, name, email, role, status, created date - Create user form with display name, email, role selector - Suspend/activate actions per user - Create API token for any user (shows plaintext once with copy button) - Role badges (admin highlighted, member muted) - Non-admin users see "Admin access required" message - Keyboard shortcut: Cmd/Ctrl+5 switches to Users tab CSS: - Reuses routines-table styles for the user list - Badge, token-display, btn-small, btn-danger, btn-primary components Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -2213,6 +2213,7 @@ function switchTab(tab) {
|
||||
if (tab === 'memory') loadMemoryTree();
|
||||
if (tab === 'jobs') loadJobs();
|
||||
if (tab === 'routines') loadRoutines();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'logs') applyLogFilters();
|
||||
if (tab === 'settings') {
|
||||
loadSettingsSubtab(currentSettingsSubtab);
|
||||
@@ -4339,6 +4340,116 @@ function formatRelativeTime(isoString) {
|
||||
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
|
||||
}
|
||||
|
||||
// --- Users (admin) ---
|
||||
|
||||
function loadUsers() {
|
||||
apiFetch('/api/admin/users').then(function(data) {
|
||||
renderUsersList(data.users || []);
|
||||
}).catch(function(err) {
|
||||
// Non-admin users get 403 — show a message instead of an error
|
||||
var tbody = document.getElementById('users-tbody');
|
||||
var empty = document.getElementById('users-empty');
|
||||
if (tbody) tbody.innerHTML = '';
|
||||
if (empty) {
|
||||
empty.style.display = 'block';
|
||||
empty.textContent = 'Admin access required to manage users.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsersList(users) {
|
||||
var tbody = document.getElementById('users-tbody');
|
||||
var empty = document.getElementById('users-empty');
|
||||
if (!users || users.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
empty.textContent = 'No users found. Create the first user to get started.';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = users.map(function(u) {
|
||||
var statusClass = u.status === 'active' ? 'active' : 'failed';
|
||||
var roleLabel = u.role === 'admin' ? '<span class="badge badge-admin">admin</span>' : '<span class="badge">member</span>';
|
||||
var actions = '';
|
||||
if (u.status === 'active') {
|
||||
actions += '<button class="btn-small btn-danger" onclick="suspendUser(\'' + u.id + '\')">Suspend</button> ';
|
||||
} else {
|
||||
actions += '<button class="btn-small btn-primary" onclick="activateUser(\'' + u.id + '\')">Activate</button> ';
|
||||
}
|
||||
actions += '<button class="btn-small" onclick="createTokenForUser(\'' + u.id + '\', \'' + escapeHtml(u.display_name) + '\')">+ Token</button>';
|
||||
return '<tr>'
|
||||
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
|
||||
+ '<td>' + escapeHtml(u.display_name) + '</td>'
|
||||
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
|
||||
+ '<td>' + roleLabel + '</td>'
|
||||
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
|
||||
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
|
||||
+ '<td>' + actions + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function suspendUser(userId) {
|
||||
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
|
||||
.then(function() { loadUsers(); })
|
||||
.catch(function(e) { alert('Failed to suspend user: ' + e.message); });
|
||||
}
|
||||
|
||||
function activateUser(userId) {
|
||||
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
|
||||
.then(function() { loadUsers(); })
|
||||
.catch(function(e) { alert('Failed to activate user: ' + e.message); });
|
||||
}
|
||||
|
||||
function createTokenForUser(userId, displayName) {
|
||||
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
|
||||
if (!tokenName) return;
|
||||
apiFetch('/api/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: tokenName, user_id: userId }),
|
||||
}).then(function(data) {
|
||||
var banner = document.getElementById('users-token-result');
|
||||
banner.style.display = 'block';
|
||||
banner.innerHTML = '<strong>Token created!</strong> Copy this now — it won\'t be shown again:<br>'
|
||||
+ '<code class="token-display">' + escapeHtml(data.token) + '</code>'
|
||||
+ '<button class="btn-small" onclick="navigator.clipboard.writeText(\'' + escapeHtml(data.token) + '\');this.textContent=\'Copied!\'">Copy</button>';
|
||||
}).catch(function(e) { alert('Failed to create token: ' + e.message); });
|
||||
}
|
||||
|
||||
// Wire up Users tab create form
|
||||
document.getElementById('users-create-btn')?.addEventListener('click', function() {
|
||||
document.getElementById('users-create-form').style.display = 'flex';
|
||||
document.getElementById('users-token-result').style.display = 'none';
|
||||
document.getElementById('user-display-name').focus();
|
||||
});
|
||||
|
||||
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
|
||||
document.getElementById('users-create-form').style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('users-create-submit')?.addEventListener('click', function() {
|
||||
var displayName = document.getElementById('user-display-name').value.trim();
|
||||
var email = document.getElementById('user-email').value.trim();
|
||||
var role = document.getElementById('user-role').value;
|
||||
if (!displayName) { alert('Display name is required'); return; }
|
||||
|
||||
apiFetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
display_name: displayName,
|
||||
email: email || undefined,
|
||||
role: role,
|
||||
}),
|
||||
}).then(function() {
|
||||
document.getElementById('users-create-form').style.display = 'none';
|
||||
document.getElementById('user-display-name').value = '';
|
||||
document.getElementById('user-email').value = '';
|
||||
loadUsers();
|
||||
}).catch(function(e) { alert('Failed to create user: ' + e.message); });
|
||||
});
|
||||
|
||||
// --- Gateway status widget ---
|
||||
|
||||
let gatewayStatusInterval = null;
|
||||
@@ -4933,7 +5044,7 @@ document.addEventListener('keydown', (e) => {
|
||||
// Mod+1-5: switch tabs
|
||||
if (mod && e.key >= '1' && e.key <= '5') {
|
||||
e.preventDefault();
|
||||
const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings'];
|
||||
const tabs = ['chat', 'memory', 'jobs', 'routines', 'users', 'settings'];
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (tabs[idx]) switchTab(tabs[idx]);
|
||||
return;
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
||||
<button data-tab="users" data-i18n="tab.users">Users</button>
|
||||
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
|
||||
<div class="spacer"></div>
|
||||
|
||||
@@ -282,6 +283,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab (admin) -->
|
||||
<div class="tab-panel" id="tab-users">
|
||||
<div class="users-container">
|
||||
<div class="users-header">
|
||||
<h3>User Management</h3>
|
||||
<button id="users-create-btn" class="btn-primary">+ New User</button>
|
||||
</div>
|
||||
<div id="users-create-form" style="display:none" class="users-form">
|
||||
<input type="text" id="user-display-name" placeholder="Display name" />
|
||||
<input type="email" id="user-email" placeholder="Email (optional)" />
|
||||
<select id="user-role"><option value="member">Member</option><option value="admin">Admin</option></select>
|
||||
<button id="users-create-submit" class="btn-primary">Create</button>
|
||||
<button id="users-create-cancel" class="btn-secondary">Cancel</button>
|
||||
</div>
|
||||
<div id="users-token-result" style="display:none" class="users-token-banner"></div>
|
||||
<table class="routines-table" id="users-table">
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Display Name</th><th>Email</th><th>Role</th><th>Status</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
<div id="users-empty" class="empty-state" style="display:none">No users found. Create the first user to get started.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-panel" id="tab-settings">
|
||||
<div class="settings-layout">
|
||||
|
||||
@@ -5429,3 +5429,22 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
|
||||
--text-muted: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Users Tab --- */
|
||||
.users-container { padding: 1rem; }
|
||||
.users-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.users-header h3 { margin: 0; font-size: 1.1rem; }
|
||||
.users-form { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.users-form input, .users-form select { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem; }
|
||||
.users-token-banner { background: var(--bg-tertiary); border: 1px solid var(--accent); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
|
||||
.token-display { display: inline-block; padding: 0.3rem 0.6rem; background: var(--bg-primary); border-radius: 4px; font-family: var(--font-mono); word-break: break-all; margin: 0.4rem 0; user-select: all; }
|
||||
.user-id { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
|
||||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge-admin { background: var(--accent); color: #fff; }
|
||||
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
|
||||
.btn-small:hover { background: var(--bg-tertiary); }
|
||||
.btn-danger { border-color: #ef4444; color: #ef4444; }
|
||||
.btn-danger:hover { background: #ef4444; color: #fff; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||
|
||||
Reference in New Issue
Block a user