Feature: Implement user management feature with role support
This commit is contained in:
@@ -275,6 +275,118 @@ inline std::string GetWebPageHTML() {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.logout-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
|
||||
.users-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin-left: 10px;
|
||||
display: none;
|
||||
}
|
||||
.users-btn.visible { display: inline-block; }
|
||||
.users-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
|
||||
/* User Management Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-content {
|
||||
background: rgba(22, 33, 62, 0.98);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(233, 69, 96, 0.2);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.modal-header h3 { color: #e94560; margin: 0; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: #e94560; }
|
||||
.user-form { margin-bottom: 24px; }
|
||||
.user-form h4 { color: #ccc; margin-bottom: 12px; font-size: 14px; }
|
||||
.user-form input, .user-form select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 52, 96, 0.8);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.user-form input:focus, .user-form select:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
.user-form button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.user-form button:hover { transform: translateY(-1px); }
|
||||
.user-list h4 { color: #ccc; margin-bottom: 12px; font-size: 14px; }
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: rgba(15, 52, 96, 0.5);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.user-item .user-info { flex: 1; }
|
||||
.user-item .username { color: #fff; font-weight: 500; }
|
||||
.user-item .role { color: #888; font-size: 12px; }
|
||||
.user-item .role.admin { color: #e94560; }
|
||||
.user-item .delete-btn {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
border: 1px solid rgba(231, 76, 60, 0.5);
|
||||
color: #e74c3c;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.user-item .delete-btn:hover { background: rgba(231, 76, 60, 0.4); }
|
||||
.user-item .delete-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.user-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
|
||||
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
|
||||
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
|
||||
)HTML";
|
||||
|
||||
// Part 3: Device card styles
|
||||
@@ -814,6 +926,7 @@ inline std::string GetWebPageHTML() {
|
||||
<button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="getDevices()">Refresh</button>
|
||||
<button class="users-btn" id="users-btn" onclick="openUsersModal()">Users</button>
|
||||
<button class="logout-btn" onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -871,6 +984,31 @@ inline std::string GetWebPageHTML() {
|
||||
<div class="zoom-indicator" id="zoom-indicator">100%</div>
|
||||
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
||||
</div>
|
||||
|
||||
<!-- User Management Modal -->
|
||||
<div class="modal-overlay" id="users-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>User Management</h3>
|
||||
<button class="modal-close" onclick="closeUsersModal()">×</button>
|
||||
</div>
|
||||
<div id="user-msg"></div>
|
||||
<div class="user-form">
|
||||
<h4>Create New User</h4>
|
||||
<input type="text" id="new-username" placeholder="Username" autocomplete="off">
|
||||
<input type="password" id="new-password" placeholder="Password" autocomplete="new-password">
|
||||
<select id="new-role">
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
<option value="admin">Admin (full access)</option>
|
||||
</select>
|
||||
<button onclick="createUser()">Create User</button>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<h4>Existing Users</h4>
|
||||
<div id="users-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)HTML";
|
||||
|
||||
// Part 7: JavaScript - State and WebSocket
|
||||
@@ -1030,11 +1168,28 @@ inline std::string GetWebPageHTML() {
|
||||
challengeNonce = msg.nonce || '';
|
||||
console.log('Received challenge nonce');
|
||||
break;
|
||||
case 'salt':
|
||||
if (msg.ok) {
|
||||
completeLogin(msg.salt || '');
|
||||
} else {
|
||||
pendingLogin = null; // Clear pending state on error
|
||||
document.getElementById('login-error').textContent = msg.msg || 'Failed to get salt';
|
||||
}
|
||||
break;
|
||||
case 'login_result':
|
||||
if (msg.ok) {
|
||||
token = msg.token;
|
||||
currentUserRole = msg.role || 'viewer';
|
||||
sessionStorage.setItem('token', token);
|
||||
sessionStorage.setItem('role', currentUserRole);
|
||||
document.getElementById('login-error').textContent = '';
|
||||
// Show Users button for admin only
|
||||
const usersBtn = document.getElementById('users-btn');
|
||||
if (currentUserRole === 'admin') {
|
||||
usersBtn.classList.add('visible');
|
||||
} else {
|
||||
usersBtn.classList.remove('visible');
|
||||
}
|
||||
showPage('devices-page');
|
||||
getDevices();
|
||||
} else {
|
||||
@@ -1096,6 +1251,29 @@ inline std::string GetWebPageHTML() {
|
||||
getDevices();
|
||||
}
|
||||
break;
|
||||
case 'create_user_result':
|
||||
if (msg.ok) {
|
||||
showUserMsg('User created successfully', false);
|
||||
document.getElementById('new-username').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
listUsers();
|
||||
} else {
|
||||
showUserMsg(msg.msg || 'Failed to create user', true);
|
||||
}
|
||||
break;
|
||||
case 'delete_user_result':
|
||||
if (msg.ok) {
|
||||
showUserMsg('User deleted', false);
|
||||
listUsers();
|
||||
} else {
|
||||
showUserMsg(msg.msg || 'Failed to delete user', true);
|
||||
}
|
||||
break;
|
||||
case 'list_users_result':
|
||||
if (msg.ok) {
|
||||
renderUsersList(msg.users);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
)HTML";
|
||||
@@ -1383,6 +1561,9 @@ inline std::string GetWebPageHTML() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pending login state for salt-based auth
|
||||
let pendingLogin = null;
|
||||
|
||||
async function login() {
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
@@ -1390,8 +1571,18 @@ inline std::string GetWebPageHTML() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) { document.getElementById('login-error').textContent = 'Not connected'; return; }
|
||||
if (!challengeNonce) { document.getElementById('login-error').textContent = 'No challenge received'; return; }
|
||||
|
||||
// Compute password hash (same as server stores)
|
||||
passwordHash = await sha256(password);
|
||||
// Store pending login info and request salt first
|
||||
pendingLogin = { username, password };
|
||||
ws.send(JSON.stringify({ cmd: 'get_salt', username }));
|
||||
}
|
||||
|
||||
async function completeLogin(salt) {
|
||||
if (!pendingLogin) return;
|
||||
const { username, password } = pendingLogin;
|
||||
pendingLogin = null;
|
||||
|
||||
// Compute password hash with salt: SHA256(password + salt)
|
||||
passwordHash = await sha256(password + salt);
|
||||
// Compute response: SHA256(passwordHash + nonce)
|
||||
const response = await sha256(passwordHash + challengeNonce);
|
||||
ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce }));
|
||||
@@ -1409,6 +1600,75 @@ inline std::string GetWebPageHTML() {
|
||||
sessionStorage.removeItem('token');
|
||||
devices = [];
|
||||
showPage('login-page');
|
||||
// Hide users button
|
||||
document.getElementById('users-btn').classList.remove('visible');
|
||||
}
|
||||
|
||||
// User Management Functions
|
||||
let currentUserRole = 'viewer';
|
||||
|
||||
function openUsersModal() {
|
||||
document.getElementById('users-modal').classList.add('active');
|
||||
document.getElementById('user-msg').innerHTML = '';
|
||||
listUsers();
|
||||
}
|
||||
|
||||
function closeUsersModal() {
|
||||
document.getElementById('users-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
function showUserMsg(msg, isError) {
|
||||
const el = document.getElementById('user-msg');
|
||||
el.className = 'user-msg ' + (isError ? 'error' : 'success');
|
||||
el.textContent = msg;
|
||||
setTimeout(() => { el.innerHTML = ''; }, 3000);
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
const username = document.getElementById('new-username').value.trim();
|
||||
const password = document.getElementById('new-password').value;
|
||||
const role = document.getElementById('new-role').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showUserMsg('Username and password are required', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||
ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role }));
|
||||
}
|
||||
}
|
||||
|
||||
function deleteUser(username) {
|
||||
if (!confirm('Delete user "' + username + '"?')) return;
|
||||
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||
ws.send(JSON.stringify({ cmd: 'delete_user', token, username }));
|
||||
}
|
||||
}
|
||||
|
||||
function listUsers() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||
ws.send(JSON.stringify({ cmd: 'list_users', token }));
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsersList(users) {
|
||||
const container = document.getElementById('users-list');
|
||||
if (!users || users.length === 0) {
|
||||
container.innerHTML = '<div style="color:#666;padding:12px;">No users</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = users.map(u => {
|
||||
const isAdmin = u.role === 'admin';
|
||||
const canDelete = u.username !== 'admin'; // Cannot delete built-in admin
|
||||
return '<div class="user-item">' +
|
||||
'<div class="user-info">' +
|
||||
'<div class="username">' + escapeHtml(u.username) + '</div>' +
|
||||
'<div class="role ' + (isAdmin ? 'admin' : '') + '">' + u.role + '</div>' +
|
||||
'</div>' +
|
||||
(canDelete ? '<button class="delete-btn" onclick="deleteUser(\'' + escapeHtml(u.username) + '\')">Delete</button>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getDevices() {
|
||||
@@ -2642,8 +2902,13 @@ inline std::string GetWebPageHTML() {
|
||||
bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
|
||||
bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
|
||||
bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar'));
|
||||
// Restore token from sessionStorage
|
||||
// Restore token and role from sessionStorage
|
||||
token = sessionStorage.getItem('token');
|
||||
currentUserRole = sessionStorage.getItem('role') || 'viewer';
|
||||
// Show Users button for admin only (will be updated after login verification)
|
||||
if (token && currentUserRole === 'admin') {
|
||||
document.getElementById('users-btn').classList.add('visible');
|
||||
}
|
||||
connectWebSocket();
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user