Add superadmin role, place sharing UI, and merge main
This commit is contained in:
parent
4ee90f46ee
commit
c72c3b353f
56
public/admin/index.html
Normal file
56
public/admin/index.html
Normal 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>
|
||||
|
|
@ -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
173
public/js/admin.js
Normal 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();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue