Feature: filter device visibility by allowed groups for web user
This commit is contained in:
@@ -997,10 +997,14 @@ inline std::string GetWebPageHTML() {
|
||||
<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">
|
||||
<select id="new-role" onchange="onRoleChange()">
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
<option value="admin">Admin (full access)</option>
|
||||
</select>
|
||||
<div class="groups-section" id="groups-section">
|
||||
<label style="font-size:13px;color:#aaa;display:block;margin:8px 0 4px;">Allowed Groups:</label>
|
||||
<div id="groups-checkboxes" style="max-height:120px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:6px;padding:6px 8px;"></div>
|
||||
</div>
|
||||
<button onclick="createUser()">Create User</button>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
@@ -1286,6 +1290,11 @@ inline std::string GetWebPageHTML() {
|
||||
renderUsersList(msg.users);
|
||||
}
|
||||
break;
|
||||
case 'groups':
|
||||
if (msg.ok) {
|
||||
renderGroupsCheckboxes(msg.groups);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
)HTML";
|
||||
@@ -1661,7 +1670,35 @@ inline std::string GetWebPageHTML() {
|
||||
function openUsersModal() {
|
||||
document.getElementById('users-modal').classList.add('active');
|
||||
document.getElementById('user-msg').innerHTML = '';
|
||||
document.getElementById('new-role').value = 'viewer'; // Reset to default
|
||||
onRoleChange(); // Update groups section visibility
|
||||
listUsers();
|
||||
getGroups();
|
||||
}
|
||||
|
||||
function getGroups() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||
ws.send(JSON.stringify({ cmd: 'get_groups', token }));
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupsCheckboxes(groups) {
|
||||
const container = document.getElementById('groups-checkboxes');
|
||||
if (!groups || groups.length === 0) {
|
||||
container.innerHTML = '<span style="color:#666;font-size:12px;">No groups available</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = groups.map(g =>
|
||||
'<label style="display:flex;align-items:center;padding:3px 0;cursor:pointer;white-space:nowrap;">' +
|
||||
'<input type="checkbox" value="' + escapeHtml(g) + '" style="margin:0 6px 0 0;flex-shrink:0;width:14px;height:14px;">' +
|
||||
escapeHtml(g) + '</label>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function onRoleChange() {
|
||||
const role = document.getElementById('new-role').value;
|
||||
const groupsSection = document.getElementById('groups-section');
|
||||
groupsSection.style.display = (role === 'admin') ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function closeUsersModal() {
|
||||
@@ -1685,8 +1722,12 @@ inline std::string GetWebPageHTML() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect selected groups
|
||||
const checkboxes = document.querySelectorAll('#groups-checkboxes input[type="checkbox"]:checked');
|
||||
const allowed_groups = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||
ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role }));
|
||||
ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role, allowed_groups }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1712,10 +1753,14 @@ inline std::string GetWebPageHTML() {
|
||||
container.innerHTML = users.map(u => {
|
||||
const isAdmin = u.role === 'admin';
|
||||
const canDelete = u.username !== 'admin'; // Cannot delete built-in admin
|
||||
const groups = u.allowed_groups || [];
|
||||
const groupsText = u.username === 'admin' ? '(all)' :
|
||||
(groups.length > 0 ? groups.join(', ') : '(none)');
|
||||
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 class="groups" style="font-size:11px;color:#888;margin-top:2px;">Groups: ' + escapeHtml(groupsText) + '</div>' +
|
||||
'</div>' +
|
||||
(canDelete ? '<button class="delete-btn" onclick="deleteUser(\'' + escapeHtml(u.username) + '\')">Delete</button>' : '') +
|
||||
'</div>';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <shlobj.h>
|
||||
#include <set>
|
||||
|
||||
// Algorithm constants (same as ScreenSpyDlg.cpp)
|
||||
#define ALGORITHM_H264 2
|
||||
@@ -363,6 +364,8 @@ void CWebService::ServerThread(int port) {
|
||||
HandleDeleteUser(ws_ptr, msg);
|
||||
} else if (cmd == "list_users") {
|
||||
HandleListUsers(ws_ptr, token);
|
||||
} else if (cmd == "get_groups") {
|
||||
HandleGetGroups(ws_ptr, token);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -566,7 +569,7 @@ void CWebService::HandleGetDevices(void* ws_ptr, const std::string& token) {
|
||||
return;
|
||||
}
|
||||
|
||||
SendText(ws_ptr, BuildDeviceListJson());
|
||||
SendText(ws_ptr, BuildDeviceListJson(username));
|
||||
}
|
||||
|
||||
void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id) {
|
||||
@@ -588,6 +591,32 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
||||
return;
|
||||
}
|
||||
|
||||
// Check group permission (admin can access all devices)
|
||||
if (username != "admin") {
|
||||
std::string deviceGroup = ctx->GetGroupName();
|
||||
if (deviceGroup.empty()) deviceGroup = "default";
|
||||
|
||||
bool hasAccess = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||||
for (const auto& u : m_Users) {
|
||||
if (u.username == username) {
|
||||
for (const auto& g : u.allowed_groups) {
|
||||
if (g == deviceGroup) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasAccess) {
|
||||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Permission denied"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check max clients per device
|
||||
int current_count = GetWebClientCount(device_id);
|
||||
if (current_count >= m_nMaxClientsPerDevice) {
|
||||
@@ -954,12 +983,23 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
|
||||
std::string newPassword = root.get("password", "").asString();
|
||||
std::string newRole = root.get("role", "viewer").asString();
|
||||
|
||||
// Parse allowed_groups array
|
||||
std::vector<std::string> allowedGroups;
|
||||
const Json::Value& groups = root["allowed_groups"];
|
||||
if (groups.isArray()) {
|
||||
for (const auto& g : groups) {
|
||||
if (g.isString() && !g.asString().empty()) {
|
||||
allowedGroups.push_back(g.asString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newUsername.empty() || newPassword.empty()) {
|
||||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Username and password required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (CreateUser(newUsername, newPassword, newRole)) {
|
||||
if (CreateUser(newUsername, newPassword, newRole, allowedGroups)) {
|
||||
SendText(ws_ptr, BuildJsonResponse("create_user_result", true));
|
||||
} else {
|
||||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)"));
|
||||
@@ -1009,18 +1049,27 @@ void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto users = ListUsers();
|
||||
|
||||
Json::Value res;
|
||||
res["cmd"] = "list_users_result";
|
||||
res["ok"] = true;
|
||||
|
||||
Json::Value usersArray(Json::arrayValue);
|
||||
for (const auto& u : users) {
|
||||
Json::Value user;
|
||||
user["username"] = u.first;
|
||||
user["role"] = u.second;
|
||||
usersArray.append(user);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||||
for (const auto& u : m_Users) {
|
||||
Json::Value user;
|
||||
user["username"] = u.username;
|
||||
user["role"] = u.role;
|
||||
|
||||
// Include allowed_groups
|
||||
Json::Value groups(Json::arrayValue);
|
||||
for (const auto& g : u.allowed_groups) {
|
||||
groups.append(g);
|
||||
}
|
||||
user["allowed_groups"] = groups;
|
||||
|
||||
usersArray.append(user);
|
||||
}
|
||||
}
|
||||
res["users"] = usersArray;
|
||||
|
||||
@@ -1030,6 +1079,48 @@ void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
|
||||
SendText(ws_ptr, json);
|
||||
}
|
||||
|
||||
void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) {
|
||||
SendText(ws_ptr, BuildJsonResponse("groups", false, "Invalid token"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only admin can get groups list (for user management)
|
||||
if (role != "admin") {
|
||||
SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all unique groups from online devices
|
||||
std::set<std::string> groups;
|
||||
groups.insert("default"); // Always include default group
|
||||
|
||||
if (m_pParentDlg) {
|
||||
EnterCriticalSection(&m_pParentDlg->m_cs);
|
||||
for (context* ctx : m_pParentDlg->m_HostList) {
|
||||
if (!ctx || !ctx->IsLogin()) continue;
|
||||
std::string g = ctx->GetGroupName();
|
||||
groups.insert(g.empty() ? "default" : g);
|
||||
}
|
||||
LeaveCriticalSection(&m_pParentDlg->m_cs);
|
||||
}
|
||||
|
||||
// Build response
|
||||
Json::Value res;
|
||||
res["cmd"] = "groups";
|
||||
res["ok"] = true;
|
||||
res["groups"] = Json::Value(Json::arrayValue);
|
||||
for (const auto& g : groups) {
|
||||
res["groups"].append(g);
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder builder;
|
||||
builder["indentation"] = "";
|
||||
std::string json = Json::writeString(builder, res);
|
||||
SendText(ws_ptr, json);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Token Management (delegated to WebServiceAuth module)
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
@@ -1170,6 +1261,14 @@ void CWebService::LoadUsers() {
|
||||
user.salt = u.get("salt", "").asString();
|
||||
user.role = u.get("role", "viewer").asString();
|
||||
|
||||
// Load allowed_groups
|
||||
const Json::Value& groups = u["allowed_groups"];
|
||||
if (groups.isArray()) {
|
||||
for (const auto& g : groups) {
|
||||
user.allowed_groups.push_back(g.asString());
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.password_hash.empty()) {
|
||||
m_Users.push_back(user);
|
||||
loaded++;
|
||||
@@ -1197,6 +1296,14 @@ void CWebService::SaveUsers() {
|
||||
user["password_hash"] = u.password_hash;
|
||||
user["salt"] = u.salt;
|
||||
user["role"] = u.role;
|
||||
|
||||
// Save allowed_groups
|
||||
Json::Value groups(Json::arrayValue);
|
||||
for (const auto& g : u.allowed_groups) {
|
||||
groups.append(g);
|
||||
}
|
||||
user["allowed_groups"] = groups;
|
||||
|
||||
users.append(user);
|
||||
}
|
||||
|
||||
@@ -1217,7 +1324,8 @@ void CWebService::SaveUsers() {
|
||||
Mprintf("[WebService] Saved %d users to users.json\n", (int)users.size());
|
||||
}
|
||||
|
||||
bool CWebService::CreateUser(const std::string& username, const std::string& password, const std::string& role) {
|
||||
bool CWebService::CreateUser(const std::string& username, const std::string& password, const std::string& role,
|
||||
const std::vector<std::string>& allowed_groups) {
|
||||
if (username.empty() || password.empty()) return false;
|
||||
if (username == "admin") return false; // Cannot create user named "admin"
|
||||
if (role != "admin" && role != "viewer") return false;
|
||||
@@ -1236,9 +1344,11 @@ bool CWebService::CreateUser(const std::string& username, const std::string& pas
|
||||
user.salt = GenerateSalt();
|
||||
user.password_hash = WSAuth::ComputeSHA256(password + user.salt);
|
||||
user.role = role;
|
||||
user.allowed_groups = allowed_groups;
|
||||
|
||||
m_Users.push_back(user);
|
||||
Mprintf("[WebService] Created user: %s (role: %s)\n", username.c_str(), role.c_str());
|
||||
Mprintf("[WebService] Created user: %s (role: %s, groups: %d)\n",
|
||||
username.c_str(), role.c_str(), (int)allowed_groups.size());
|
||||
}
|
||||
|
||||
// Save to file (outside lock scope since SaveUsers acquires its own lock)
|
||||
@@ -1295,17 +1405,47 @@ std::string CWebService::BuildJsonResponse(const std::string& cmd, bool ok, cons
|
||||
return Json::writeString(builder, res);
|
||||
}
|
||||
|
||||
std::string CWebService::BuildDeviceListJson() {
|
||||
std::string CWebService::BuildDeviceListJson(const std::string& username) {
|
||||
Json::Value res;
|
||||
res["cmd"] = "device_list";
|
||||
res["devices"] = Json::Value(Json::arrayValue);
|
||||
|
||||
// Get user's allowed groups for filtering (skip for admin or empty username)
|
||||
std::vector<std::string> allowedGroups;
|
||||
bool filterByGroup = false;
|
||||
if (!username.empty() && username != "admin") {
|
||||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||||
for (const auto& u : m_Users) {
|
||||
if (u.username == username) {
|
||||
allowedGroups = u.allowed_groups;
|
||||
filterByGroup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m_pParentDlg) {
|
||||
// Access device list with lock
|
||||
EnterCriticalSection(&m_pParentDlg->m_cs);
|
||||
for (context* ctx : m_pParentDlg->m_HostList) {
|
||||
if (!ctx || !ctx->IsLogin()) continue;
|
||||
|
||||
// Get device group (empty = "default")
|
||||
std::string deviceGroup = ctx->GetGroupName();
|
||||
if (deviceGroup.empty()) deviceGroup = "default";
|
||||
|
||||
// Filter by allowed groups if user is not admin
|
||||
if (filterByGroup) {
|
||||
bool allowed = false;
|
||||
for (const auto& g : allowedGroups) {
|
||||
if (g == deviceGroup) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allowed) continue; // Skip device not in allowed groups
|
||||
}
|
||||
|
||||
Json::Value device;
|
||||
// Use string for ID to avoid JavaScript number precision loss
|
||||
device["id"] = std::to_string(ctx->GetClientID());
|
||||
@@ -1332,6 +1472,9 @@ std::string CWebService::BuildDeviceListJson() {
|
||||
device["activeWindow"] = AnsiToUtf8(activeWindow);
|
||||
device["online"] = true;
|
||||
|
||||
// Add device group to response
|
||||
device["group"] = deviceGroup;
|
||||
|
||||
// Get screen info from client's reported resolution
|
||||
// Format: "n:MxN" where n=monitor count, M=width, N=height
|
||||
CString resolution = ctx->GetAdditionalData(RES_RESOLUTION);
|
||||
|
||||
@@ -43,6 +43,7 @@ struct WebUser {
|
||||
std::string password_hash; // SHA256(password + salt)
|
||||
std::string salt;
|
||||
std::string role; // "admin" | "viewer"
|
||||
std::vector<std::string> allowed_groups; // Groups this user can view (empty = no access, admin = all)
|
||||
};
|
||||
|
||||
// Device info for web clients
|
||||
@@ -79,7 +80,8 @@ public:
|
||||
void SetAdminPassword(const std::string& password);
|
||||
|
||||
// User management
|
||||
bool CreateUser(const std::string& username, const std::string& password, const std::string& role);
|
||||
bool CreateUser(const std::string& username, const std::string& password, const std::string& role,
|
||||
const std::vector<std::string>& allowed_groups = {});
|
||||
bool DeleteUser(const std::string& username);
|
||||
std::vector<std::pair<std::string, std::string>> ListUsers(); // Returns [(username, role), ...]
|
||||
|
||||
@@ -144,7 +146,7 @@ private:
|
||||
|
||||
// JSON helpers
|
||||
std::string BuildJsonResponse(const std::string& cmd, bool ok, const std::string& msg = "");
|
||||
std::string BuildDeviceListJson();
|
||||
std::string BuildDeviceListJson(const std::string& username = "");
|
||||
|
||||
// Password verification
|
||||
bool VerifyPassword(const std::string& input, const WebUser& user);
|
||||
@@ -157,6 +159,7 @@ private:
|
||||
void HandleCreateUser(void* ws_ptr, const std::string& msg);
|
||||
void HandleDeleteUser(void* ws_ptr, const std::string& msg);
|
||||
void HandleListUsers(void* ws_ptr, const std::string& token);
|
||||
void HandleGetGroups(void* ws_ptr, const std::string& token);
|
||||
|
||||
// Send to WebSocket
|
||||
void SendText(void* ws_ptr, const std::string& text);
|
||||
|
||||
Reference in New Issue
Block a user