Add superadmin role, place sharing UI, and merge main

This commit is contained in:
copilot-swe-agent[bot] 2026-07-02 15:14:38 +00:00 committed by GitHub
parent 4ee90f46ee
commit c72c3b353f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 291 additions and 1 deletions

56
public/admin/index.html Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User administration - NOTXCS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<h2>NOTXCS</h2>
<div class="user-info">Signed in as <strong id="username-display"></strong> <span id="role-badge" class="badge type-badge"></span></div>
<a href="/dashboard" style="display:block;margin-bottom:12px;color:var(--accent);font-size:13px;">Back to dashboard</a>
<button class="secondary" id="logout-btn" style="margin-bottom: 16px;">Log out</button>
</aside>
<main class="main">
<div class="main-header">
<h1>User administration</h1>
</div>
<div class="card">
<h3>Create user</h3>
<form id="add-user-form" class="inline-form">
<div class="field-group">
<label for="new-user-username">Username</label>
<input type="text" id="new-user-username" required>
</div>
<div class="field-group">
<label for="new-user-password">Password</label>
<input type="password" id="new-user-password" required>
</div>
<div class="field-group">
<label for="new-user-role">Role</label>
<select id="new-user-role">
<option value="1">User</option>
<option value="2">Elevated</option>
<option value="3">Admin</option>
</select>
</div>
<button type="submit" class="primary">Create user</button>
</form>
</div>
<div class="card">
<h3>Users</h3>
<div id="users-empty" class="empty-state hidden">No users found.</div>
<ul class="acl-list" id="users-list"></ul>
</div>
</main>
</div>
<script src="/js/admin.js"></script>
</body>
</html>

View file

@ -11,7 +11,7 @@
<aside class="sidebar">
<h2>NOTXCS</h2>
<div class="user-info">Signed in as <strong id="username-display"></strong> <span id="role-badge" class="badge type-badge"></span></div>
<a id="admin-link" class="hidden" href="/admin.html" style="display:block;margin-bottom:12px;color:var(--accent);font-size:13px;">Manage users</a>
<a id="admin-link" class="hidden" href="/admin" style="display:block;margin-bottom:12px;color:var(--accent);font-size:13px;">Manage users</a>
<button class="secondary" id="logout-btn" style="margin-bottom: 16px;">Log out</button>
<ul class="place-list" id="place-list"></ul>

173
public/js/admin.js Normal file
View file

@ -0,0 +1,173 @@
let currentUser = null;
let users = [];
const ROLE_LABELS = {
1: 'User',
2: 'Elevated',
3: 'Admin',
4: 'Superadmin'
};
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options
});
const data = await res.json().catch(() => ({ success: false, message: 'Invalid response from server' }));
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Not authenticated');
}
return data;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async function init() {
const me = await api('/auth/me');
if (!me.success) {
window.location.href = '/login';
return;
}
if (me.user.role < 3) {
window.location.href = '/dashboard';
return;
}
currentUser = me.user;
document.getElementById('username-display').textContent = me.user.username;
document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
// Only a superadmin can grant the admin role; hide that option for regular admins.
if (currentUser.role < 4) {
document.querySelector('#new-user-role option[value="3"]').remove();
}
await loadUsers();
}
async function loadUsers() {
const data = await api('/admin/users');
users = data.users || [];
renderUsersList();
}
function roleOptions(selectedRole) {
const canGrantAdmin = currentUser.role >= 4;
const options = [
{ value: 1, label: 'User' },
{ value: 2, label: 'Elevated' }
];
if (canGrantAdmin || selectedRole === 3) {
options.push({ value: 3, label: 'Admin' });
}
return options.map(o => `<option value="${o.value}" ${o.value === selectedRole ? 'selected' : ''}>${o.label}</option>`).join('');
}
function renderUsersList() {
const list = document.getElementById('users-list');
const empty = document.getElementById('users-empty');
list.innerHTML = '';
if (users.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
users.forEach((user) => {
const isSuperadmin = user.role >= 4;
const canManage = currentUser.role >= 4 || user.role < 3;
const li = document.createElement('li');
li.className = 'acl-entry';
if (isSuperadmin) {
li.innerHTML = `
<span class="acl-description">${escapeHtml(user.username)}</span>
<span class="badge type-badge">Superadmin</span>
`;
list.appendChild(li);
return;
}
li.innerHTML = `
<span class="acl-description">${escapeHtml(user.username)}</span>
<select class="role-select" ${canManage ? '' : 'disabled'}>${roleOptions(user.role)}</select>
<input type="password" class="password-input" placeholder="New password" style="margin:0;width:160px;">
<button class="secondary save-btn" ${canManage ? '' : 'disabled'}>Save</button>
<button class="danger delete-btn" ${canManage && user.id !== currentUser.id ? '' : 'disabled'}>Delete</button>
`;
li.querySelector('.save-btn').addEventListener('click', () => saveUser(user.id, li));
li.querySelector('.delete-btn').addEventListener('click', () => deleteUser(user.id, user.username));
list.appendChild(li);
});
}
async function saveUser(userId, li) {
const role = parseInt(li.querySelector('.role-select').value, 10);
const password = li.querySelector('.password-input').value;
const body = { role };
if (password) {
if (password.length < 6) {
alert('Password must be at least 6 characters');
return;
}
body.password = password;
}
const result = await api(`/admin/users/${encodeURIComponent(userId)}`, {
method: 'PUT',
body: JSON.stringify(body)
});
if (result.success) {
await loadUsers();
} else {
alert(result.message || 'Failed to update user');
}
}
async function deleteUser(userId, username) {
if (!confirm(`Delete user "${username}"?`)) return;
const result = await api(`/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
if (result.success) {
await loadUsers();
} else {
alert(result.message || 'Failed to delete user');
}
}
document.getElementById('add-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('new-user-username').value.trim();
const password = document.getElementById('new-user-password').value;
const role = parseInt(document.getElementById('new-user-role').value, 10);
if (!username || !password) return;
const result = await api('/admin/users', {
method: 'POST',
body: JSON.stringify({ username, password, role })
});
if (result.success) {
document.getElementById('add-user-form').reset();
await loadUsers();
} else {
alert(result.message || 'Failed to create user');
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
window.location.href = '/login';
});
init();

View file

@ -582,6 +582,67 @@ document.getElementById('group-modal').addEventListener('click', (e) => {
if (e.target.id === 'group-modal') closeGroupModal();
});
// --- Shared place access (owner/elevated/admin can grant other users access to a place's settings) ---
async function loadSharedAccess() {
if (!currentPlaceId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access`);
renderSharedAccessList(data.grants || []);
}
function renderSharedAccessList(grants) {
const list = document.getElementById('shared-access-list');
const empty = document.getElementById('shared-access-empty');
list.innerHTML = '';
if (grants.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
grants.forEach((grant) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="acl-description">${escapeHtml(grant.username)}</span>
<button class="danger shared-access-revoke-btn">Revoke</button>
`;
li.querySelector('.shared-access-revoke-btn').addEventListener('click', () => revokeSharedAccess(grant.userId));
list.appendChild(li);
});
}
async function revokeSharedAccess(userId) {
if (!currentPlaceId) return;
if (!confirm('Revoke this user\'s access to the place?')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access/${encodeURIComponent(userId)}`, {
method: 'DELETE'
});
await loadSharedAccess();
}
document.getElementById('add-shared-access-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const username = document.getElementById('shared-access-username').value.trim();
if (!username) return;
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access`, {
method: 'POST',
body: JSON.stringify({ username })
});
if (result.success) {
document.getElementById('shared-access-username').value = '';
await loadSharedAccess();
} else {
alert(result.message || 'Failed to grant access');
}
});
// --- Scan logs viewing ---
let currentLogsReaderId = null;