dragonflight/services/web-ui/public/users.html

583 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Users — Z-AMPP</title>
<link rel="stylesheet" href="css/common.css">
<style>
.users-shell {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-content { display: none; }
.tab-content.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.table-area {
flex: 1;
overflow-y: auto;
padding: var(--sp-6);
}
.table-area::-webkit-scrollbar { width: 5px; }
.table-area::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
.section-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-4);
}
.section-title {
font-size: var(--text-md);
font-weight: 500;
}
.action-row {
display: flex;
gap: var(--sp-2);
}
.member-chips {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
margin-top: var(--sp-2);
}
.member-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 2px 8px 2px 6px;
border-radius: 100px;
font-size: var(--text-xs);
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-secondary);
}
.member-chip button {
background: none;
border: none;
padding: 0;
color: var(--text-tertiary);
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
}
.member-chip button:hover { color: var(--status-red); }
</style>
</head>
<body>
<div class="shell">
<!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="home.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
Home
</a>
<a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="projects.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
Projects
</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
<a href="editor.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
Editor
</a>
<div class="sidebar-section-label">Admin</div>
<a href="users.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
Users
</a>
<a href="tokens.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="14" height="4" rx="1"/><rect x="1" y="10" width="14" height="4" rx="1"/><path d="M4 7h1M4 12h1"/></svg>
Containers
</a>
<a href="cluster.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2"/><circle cx="2" cy="3" r="1.5"/><circle cx="14" cy="3" r="1.5"/><circle cx="2" cy="13" r="1.5"/><circle cx="14" cy="13" r="1.5"/><path d="M3.1 4.1L6.5 6.5M12.9 4.1L9.5 6.5M3.1 11.9L6.5 9.5M12.9 11.9L9.5 9.5"/></svg>
Cluster
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="userAvatar">?</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="userName"></div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
</button>
</div>
</div>
</nav>
<!-- Main -->
<div class="main">
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Users &amp; Groups</span>
</div>
</header>
<div class="users-shell">
<!-- Tabs -->
<div class="tabs" style="padding:0 var(--sp-6);background:var(--bg-panel);border-bottom:1px solid var(--border);flex-shrink:0;">
<button class="tab active" onclick="switchTab('users',this)">Users</button>
<button class="tab" onclick="switchTab('groups',this)">Groups</button>
</div>
<!-- Users tab -->
<div class="tab-content active" id="tab-users">
<div class="table-area">
<div class="section-toolbar">
<span class="section-title" id="userCount">Users</span>
<button class="btn btn-primary btn-sm" onclick="openUserPanel()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
New user
</button>
</div>
<table class="data-table" id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Display name</th>
<th>Role</th>
<th>Groups</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody id="usersTbody">
<tr><td colspan="6" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Groups tab -->
<div class="tab-content" id="tab-groups">
<div class="table-area">
<div class="section-toolbar">
<span class="section-title" id="groupCount">Groups</span>
<button class="btn btn-primary btn-sm" onclick="openGroupPanel()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
New group
</button>
</div>
<table class="data-table" id="groupsTable">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Members</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody id="groupsTbody">
<tr><td colspan="5" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- User slide panel -->
<div class="slide-overlay" id="userOverlay" onclick="closeUserPanel()"></div>
<div class="slide-panel" id="userPanel">
<div class="slide-panel-header">
<span class="slide-panel-title" id="userPanelTitle">New user</span>
<button class="btn btn-ghost btn-sm" onclick="closeUserPanel()" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="slide-panel-body">
<input type="hidden" id="editUserId">
<div class="form-group">
<label class="form-label" for="uUsername">Username</label>
<input type="text" id="uUsername" placeholder="e.g. jsmith">
</div>
<div class="form-group">
<label class="form-label" for="uDisplayName">Display name</label>
<input type="text" id="uDisplayName" placeholder="e.g. Jane Smith">
</div>
<div class="form-group">
<label class="form-label" for="uRole">Role</label>
<select id="uRole">
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="uPassword" id="uPasswordLabel">Password</label>
<input type="password" id="uPassword" placeholder="Min 8 characters">
<div class="form-hint" id="uPasswordHint"></div>
</div>
</div>
<div class="slide-panel-footer">
<button class="btn btn-ghost" onclick="closeUserPanel()">Cancel</button>
<button class="btn btn-primary" id="saveUserBtn" onclick="saveUser()">Create user</button>
</div>
</div>
<!-- Group slide panel -->
<div class="slide-overlay" id="groupOverlay" onclick="closeGroupPanel()"></div>
<div class="slide-panel" id="groupPanel">
<div class="slide-panel-header">
<span class="slide-panel-title" id="groupPanelTitle">New group</span>
<button class="btn btn-ghost btn-sm" onclick="closeGroupPanel()" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="slide-panel-body">
<input type="hidden" id="editGroupId">
<div class="form-group">
<label class="form-label" for="gName">Group name</label>
<input type="text" id="gName" placeholder="e.g. News Team">
</div>
<div class="form-group">
<label class="form-label" for="gDescription">Description</label>
<textarea id="gDescription" rows="2" placeholder="Optional description"></textarea>
</div>
<div class="form-group" id="gMembersSection" style="display:none;">
<label class="form-label">Members</label>
<div class="member-chips" id="memberChips"></div>
<select id="addMemberSelect" style="margin-top:var(--sp-2);">
<option value="">Add member…</option>
</select>
</div>
</div>
<div class="slide-panel-footer">
<button class="btn btn-ghost" onclick="closeGroupPanel()">Cancel</button>
<button class="btn btn-primary" id="saveGroupBtn" onclick="saveGroup()">Create group</button>
</div>
</div>
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js"></script>
<script>
let allUsers = [], allGroups = [], currentGroupMembers = [];
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
loadGroups();
});
// ── Tabs ──────────────────────────────────────────────────
function switchTab(name, btn) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
// ── Load users ────────────────────────────────────────────
async function loadUsers() {
const r = await getUsers();
if (!r.success) { toast('Failed to load users', r.error, 'error'); return; }
allUsers = r.data;
renderUsers();
}
function renderUsers() {
const tbody = document.getElementById('usersTbody');
document.getElementById('userCount').textContent = `${allUsers.length} user${allUsers.length !== 1 ? 's' : ''}`;
if (!allUsers.length) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">No users yet</td></tr>`;
return;
}
tbody.innerHTML = allUsers.map(u => `
<tr>
<td><code style="font-size:var(--text-xs)">${esc(u.username)}</code></td>
<td>${esc(u.display_name || '—')}</td>
<td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>
<td><span class="text-tertiary text-xs">${u.group_count || 0} group${u.group_count !== 1 ? 's' : ''}</span></td>
<td class="text-xs text-tertiary">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}</td>
<td>
<div style="display:flex;gap:var(--sp-1);justify-content:flex-end;">
<button class="btn btn-ghost btn-sm" onclick="editUser('${u.id}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="confirmDeleteUser('${u.id}',${esc(JSON.stringify(u.username))})">Delete</button>
</div>
</td>
</tr>`).join('');
}
// ── User panel ────────────────────────────────────────────
function openUserPanel(userId) {
document.getElementById('editUserId').value = '';
document.getElementById('uUsername').value = '';
document.getElementById('uDisplayName').value = '';
document.getElementById('uRole').value = 'editor';
document.getElementById('uPassword').value = '';
document.getElementById('uUsername').disabled = false;
document.getElementById('userPanelTitle').textContent = 'New user';
document.getElementById('saveUserBtn').textContent = 'Create user';
document.getElementById('uPasswordLabel').textContent = 'Password';
document.getElementById('uPasswordHint').textContent = '';
document.getElementById('userPanel').classList.add('open');
document.getElementById('userOverlay').classList.add('open');
}
function editUser(id) {
const u = allUsers.find(x => x.id === id);
if (!u) return;
document.getElementById('editUserId').value = u.id;
document.getElementById('uUsername').value = u.username;
document.getElementById('uUsername').disabled = true;
document.getElementById('uDisplayName').value = u.display_name || '';
document.getElementById('uRole').value = u.role;
document.getElementById('uPassword').value = '';
document.getElementById('userPanelTitle').textContent = 'Edit user';
document.getElementById('saveUserBtn').textContent = 'Save changes';
document.getElementById('uPasswordLabel').textContent = 'New password';
document.getElementById('uPasswordHint').textContent = 'Leave blank to keep existing password';
document.getElementById('userPanel').classList.add('open');
document.getElementById('userOverlay').classList.add('open');
}
function closeUserPanel() {
document.getElementById('userPanel').classList.remove('open');
document.getElementById('userOverlay').classList.remove('open');
}
async function saveUser() {
const id = document.getElementById('editUserId').value;
const username = document.getElementById('uUsername').value.trim();
const display_name = document.getElementById('uDisplayName').value.trim();
const role = document.getElementById('uRole').value;
const password = document.getElementById('uPassword').value;
if (!id && !username) { toast('Username required', '', 'warning'); return; }
if (!id && !password) { toast('Password required for new user', '', 'warning'); return; }
const btn = document.getElementById('saveUserBtn');
btn.disabled = true;
let r;
if (id) {
const payload = { display_name, role };
if (password) payload.password = password;
r = await updateUser(id, payload);
} else {
r = await createUser({ username, display_name, role, password });
}
btn.disabled = false;
if (r.success) {
toast(id ? 'User updated' : 'User created', '', 'success');
closeUserPanel();
loadUsers();
} else {
toast('Failed to save user', r.error, 'error');
}
}
async function confirmDeleteUser(id, name) {
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return;
const r = await deleteUser(id);
if (r.success) { toast('User deleted', '', 'success'); loadUsers(); }
else toast('Failed to delete user', r.error, 'error');
}
// ── Load groups ───────────────────────────────────────────
async function loadGroups() {
const r = await getGroups();
if (!r.success) { toast('Failed to load groups', r.error, 'error'); return; }
allGroups = r.data;
renderGroups();
}
function renderGroups() {
const tbody = document.getElementById('groupsTbody');
document.getElementById('groupCount').textContent = `${allGroups.length} group${allGroups.length !== 1 ? 's' : ''}`;
if (!allGroups.length) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">No groups yet</td></tr>`;
return;
}
tbody.innerHTML = allGroups.map(g => `
<tr>
<td style="font-weight:500">${esc(g.name)}</td>
<td class="text-secondary text-sm">${esc(g.description || '—')}</td>
<td class="text-xs text-tertiary">${g.member_count || 0} member${g.member_count !== 1 ? 's' : ''}</td>
<td class="text-xs text-tertiary">${g.created_at ? new Date(g.created_at).toLocaleDateString() : '—'}</td>
<td>
<div style="display:flex;gap:var(--sp-1);justify-content:flex-end;">
<button class="btn btn-ghost btn-sm" onclick="editGroup('${g.id}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="confirmDeleteGroup('${g.id}',${esc(JSON.stringify(g.name))})">Delete</button>
</div>
</td>
</tr>`).join('');
}
// ── Group panel ───────────────────────────────────────────
function openGroupPanel() {
document.getElementById('editGroupId').value = '';
document.getElementById('gName').value = '';
document.getElementById('gDescription').value = '';
document.getElementById('gMembersSection').style.display = 'none';
document.getElementById('groupPanelTitle').textContent = 'New group';
document.getElementById('saveGroupBtn').textContent = 'Create group';
document.getElementById('groupPanel').classList.add('open');
document.getElementById('groupOverlay').classList.add('open');
}
async function editGroup(id) {
const g = allGroups.find(x => x.id === id);
if (!g) return;
document.getElementById('editGroupId').value = g.id;
document.getElementById('gName').value = g.name;
document.getElementById('gDescription').value = g.description || '';
document.getElementById('groupPanelTitle').textContent = 'Edit group';
document.getElementById('saveGroupBtn').textContent = 'Save changes';
document.getElementById('groupPanel').classList.add('open');
document.getElementById('groupOverlay').classList.add('open');
// Load members
document.getElementById('gMembersSection').style.display = 'block';
const mr = await getGroupMembers(id);
currentGroupMembers = mr.success ? mr.data : [];
renderMemberChips(id);
// Populate add-member dropdown
const sel = document.getElementById('addMemberSelect');
const memberIds = new Set(currentGroupMembers.map(m => m.id));
sel.innerHTML = '<option value="">Add member…</option>' +
allUsers.filter(u => !memberIds.has(u.id))
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
.join('');
sel.onchange = async () => {
if (!sel.value) return;
await addGroupMember(id, sel.value);
const mr2 = await getGroupMembers(id);
currentGroupMembers = mr2.success ? mr2.data : [];
renderMemberChips(id);
// Update dropdown
const memberIds2 = new Set(currentGroupMembers.map(m => m.id));
sel.innerHTML = '<option value="">Add member…</option>' +
allUsers.filter(u => !memberIds2.has(u.id))
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
.join('');
loadGroups();
};
}
function renderMemberChips(groupId) {
const container = document.getElementById('memberChips');
if (!currentGroupMembers.length) {
container.innerHTML = `<span class="text-xs text-tertiary">No members yet</span>`;
return;
}
container.innerHTML = currentGroupMembers.map(m => `
<span class="member-chip">
${esc(m.display_name || m.username)}
<button onclick="removeMember('${groupId}','${m.id}')" title="Remove">
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" width="10" height="10"><path d="M2 2l6 6M8 2L2 8"/></svg>
</button>
</span>`).join('');
}
async function removeMember(groupId, userId) {
await removeGroupMember(groupId, userId);
const mr = await getGroupMembers(groupId);
currentGroupMembers = mr.success ? mr.data : [];
renderMemberChips(groupId);
const memberIds = new Set(currentGroupMembers.map(m => m.id));
const sel = document.getElementById('addMemberSelect');
sel.innerHTML = '<option value="">Add member…</option>' +
allUsers.filter(u => !memberIds.has(u.id))
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
.join('');
loadGroups();
}
function closeGroupPanel() {
document.getElementById('groupPanel').classList.remove('open');
document.getElementById('groupOverlay').classList.remove('open');
currentGroupMembers = [];
}
async function saveGroup() {
const id = document.getElementById('editGroupId').value;
const name = document.getElementById('gName').value.trim();
const description = document.getElementById('gDescription').value.trim();
if (!name) { toast('Group name required', '', 'warning'); return; }
const btn = document.getElementById('saveGroupBtn');
btn.disabled = true;
const r = id
? await updateGroup(id, { name, description })
: await createGroup({ name, description });
btn.disabled = false;
if (r.success) {
toast(id ? 'Group updated' : 'Group created', name, 'success');
closeGroupPanel();
loadGroups();
} else {
toast('Failed to save group', r.error, 'error');
}
}
async function confirmDeleteGroup(id, name) {
if (!confirm(`Delete group "${name}"?`)) return;
const r = await deleteGroup(id);
if (r.success) { toast('Group deleted', '', 'success'); loadGroups(); }
else toast('Failed to delete group', r.error, 'error');
}
// ── Toast ─────────────────────────────────────────────────
function toast(title, msg, type = 'info') {
const icons = {
success: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>`,
error: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11v.5"/></svg>`,
warning: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 7v3M8 12v.5"/></svg>`,
info: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v5M8 5v.5"/></svg>`,
};
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg?`<div class="toast-msg">${esc(msg)}</div>`:''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>