fix(api,ui): fix tags/groups, status type, operator permissions (#138)

UI: group chip buttons changed to icon-only (matching folder chips), Create Group tile styling unified with Create Folder, old .group-chip-action CSS replaced with unified .chip-action class.

Go server: peerResponse and singlePeerResponse now return status as int (1=active, 0=disabled) instead of string, added status_text for admin panel backward compat. Fixes RustDesk client crash 'type String is not a subtype of type int?'.

Go server: new handleUsersWithClientFallback — detects RustDesk client requests to /api/users and returns current user without requiring user.view permission. Fixes disappearing folders/groups caused by _getUsers() 403 short-circuiting _pull().

Node.js: normalisePeer updated to use status_text fallback for status_tier.

Branding: RustDesk Server Management -> BetterDesk Server Management across themes, i18n, settings. Console version bumped to 3.0.0.

This commit was made possible thanks to Insolve.
This commit is contained in:
UNITRONIX
2026-05-28 01:46:19 +02:00
parent 8558f4cf4d
commit 4d2a4e0488
11 changed files with 88 additions and 48 deletions
+40
View File
@@ -452,6 +452,46 @@ func (s *Server) handleClientUsersList(w http.ResponseWriter, r *http.Request) {
})
}
// handleUsersWithClientFallback detects RustDesk client requests (with
// ?accessible or ?pageSize) and returns the current user without requiring
// user.view permission. This prevents the client's group pull from failing
// for operator users. Admin panel requests fall through to the full
// permission-protected list handler.
// Issue #138: _getUsers() failure causes _getPeers() to never run.
func (s *Server) handleUsersWithClientFallback(w http.ResponseWriter, r *http.Request) {
// RustDesk client sends ?accessible=&status=1&pageSize=100
if r.URL.Query().Has("accessible") || r.URL.Query().Has("pageSize") {
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
// If user has user.view permission, return full list
if auth.IsSuperAdminRole(role) || auth.RoleHasPermission(role, auth.PermUserView) {
s.handleClientUsersList(w, r)
return
}
// For operators without user.view, return current user only
// so the client's _getUsers() succeeds and _getPeers() proceeds
statusInt := 1
isAdmin := auth.IsSuperAdminRole(role)
writeJSON(w, http.StatusOK, map[string]any{
"total": 1,
"data": []map[string]any{{
"name": username,
"display_name": username,
"email": "",
"note": "",
"status": statusInt,
"is_admin": isAdmin,
}},
})
return
}
// Admin panel request — require user.view permission
s.requirePermission(auth.PermUserView, s.handleListUsers)(w, r)
}
// handleCreateUser creates a new user account.
// POST /api/users
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
+28 -2
View File
@@ -310,7 +310,12 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/sysinfo_ver", s.handleClientSysinfoVer)
// User management (permission-based)
mux.HandleFunc("GET /api/users", s.requirePermission(auth.PermUserView, s.handleListUsers))
// Issue #138: RustDesk client calls GET /api/users?accessible&pageSize=100
// with operator tokens. The _getUsers() result gates the entire group pull —
// if it fails (403), _getPeers() is never called and the Available Devices
// tab stays empty. Use a wrapper that detects client requests and returns
// only the current user before the permission check.
mux.HandleFunc("GET /api/users", s.handleUsersWithClientFallback)
mux.HandleFunc("POST /api/users", s.requirePermission(auth.PermUserCreate, s.handleCreateUser))
mux.HandleFunc("PUT /api/users/{id}", s.requirePermission(auth.PermUserEdit, s.handleUpdateUser))
mux.HandleFunc("DELETE /api/users/{id}", s.requirePermission(auth.PermUserDelete, s.handleDeleteUser))
@@ -591,9 +596,14 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
return
}
// Enrich with live online status and status tier from memory map
// Enrich with live online status and status tier from memory map.
// Issue #138 hardening: override db.Peer.Status (string) with an int
// so any RustDesk client that reaches this handler without the
// ?accessible / ?pageSize detection still receives a valid int.
type peerResponse struct {
*db.Peer
Status int `json:"status"` // 1=active, 0=disabled (overrides db.Peer.Status string)
StatusText string `json:"status_text"` // Original string status for admin panel
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
@@ -615,8 +625,15 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
liveStatus = peer.StatusOnline
}
statusInt := 1
if p.Disabled {
statusInt = 0
}
result[i] = peerResponse{
Peer: p,
Status: statusInt,
StatusText: p.Status,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
@@ -740,14 +757,23 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
type singlePeerResponse struct {
*db.Peer
Status int `json:"status"` // 1=active, 0=disabled (overrides db.Peer.Status string)
StatusText string `json:"status_text"` // Original string status for admin panel
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
CDAPConnected bool `json:"cdap_connected"`
}
statusInt := 1
if p.Disabled {
statusInt = 0
}
writeJSON(w, http.StatusOK, singlePeerResponse{
Peer: p,
Status: statusInt,
StatusText: p.Status,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
+1 -1
View File
@@ -82,7 +82,7 @@ function i18nMiddleware(req, res, next) {
// Branding - inject dynamic app name and branding data
const branding = brandingService.getBranding();
res.locals.appName = branding.appName || config.appName;
res.locals.appDescription = branding.appDescription || 'RustDesk Server Management';
res.locals.appDescription = branding.appDescription || 'BetterDesk Server Management';
res.locals.branding = branding;
// Full translations object for client-side JS
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "betterdesk-console",
"version": "2.3.0",
"description": "BetterDesk Console - Professional Web Management Panel for RustDesk Server",
"version": "3.0.0",
"description": "BetterDesk Console - Professional Web Management Panel for BetterDesk Server",
"main": "server.js",
"scripts": {
"start": "node server.js",
+10 -34
View File
@@ -230,11 +230,17 @@
text-align: center;
gap: 6px 8px;
border-style: dashed;
color: var(--text-secondary);
}
.chip-add:hover {
.chip-add:hover,
.group-chip.chip-add:hover,
.folder-chip.chip-add:hover {
border-color: var(--accent-blue);
color: var(--accent-blue);
background: var(--bg-secondary, #161b22);
box-shadow: none;
transform: none;
}
.chip-add .material-icons {
@@ -337,39 +343,9 @@
pointer-events: auto;
}
.group-chip-action {
min-width: 0;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid transparent;
border-radius: var(--radius-sm, 4px);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
cursor: pointer;
padding: 0 7px;
font-size: var(--font-size-xs, 0.75rem);
font-family: inherit;
}
.group-chip-action:hover {
background: var(--bg-hover);
border-color: var(--border-hover, #484f58);
color: var(--text-primary);
}
.group-chip-action.danger:hover {
color: var(--danger, #f85149);
}
.group-chip-action .material-icons {
font-size: 14px;
}
.group-chip-action-label {
line-height: 1;
/* Group chip actions now use unified .chip-action class (same as folders) */
.group-chip .chip-action.group-delete:hover {
color: var(--accent-red);
}
.group-chip.active .chip-count {
+2 -4
View File
@@ -1618,13 +1618,11 @@
<span class="chip-label">${Utils.escapeHtml(group.name)}</span>
<span class="chip-count">${group.member_count || 0}</span>
<span class="chip-actions">
<button type="button" class="group-chip-action" data-action="edit" data-group="${Utils.escapeHtml(group.guid)}" title="${_('devices.edit_group') || 'Edit group'}">
<button type="button" class="chip-action group-chip-action" data-action="edit" data-group="${Utils.escapeHtml(group.guid)}" title="${_('devices.edit_group') || 'Edit group'}">
<span class="material-icons">edit</span>
<span class="group-chip-action-label">${_('actions.edit') || _('devices.edit_group') || 'Edit'}</span>
</button>
<button type="button" class="group-chip-action danger" data-action="delete" data-group="${Utils.escapeHtml(group.guid)}" title="${_('devices.delete_group') || 'Delete group'}">
<button type="button" class="chip-action group-chip-action group-delete" data-action="delete" data-group="${Utils.escapeHtml(group.guid)}" title="${_('devices.delete_group') || 'Delete group'}">
<span class="material-icons">delete</span>
<span class="group-chip-action-label">${_('actions.delete') || _('devices.delete_group') || 'Delete'}</span>
</button>
</span>
</span>`;
+1 -1
View File
@@ -437,7 +437,7 @@ function normalisePeer(peer) {
banned_at: peer.banned_at || null,
folder_id: peer.folder_id || null,
tags,
status_tier: peer.live_status || (peer.live_online ? 'online' : 'offline'),
status_tier: peer.live_status || peer.status_text || (peer.live_online ? 'online' : 'offline'),
uuid: peer.uuid || '',
nat_type: peer.nat_type || 0,
disabled: !!(peer.disabled || peer.soft_deleted),
+1 -1
View File
@@ -90,7 +90,7 @@ function validateBrandingUrl(value) {
const DEFAULT_BRANDING = {
// Brand identity
appName: 'BetterDesk',
appDescription: 'RustDesk Server Management',
appDescription: 'BetterDesk Server Management',
// Logo configuration
logoType: 'image', // 'icon' | 'svg' | 'image' | 'text'
+1 -1
View File
@@ -3,7 +3,7 @@
"type": "betterdesk-theme",
"branding": {
"appName": "BetterDesk",
"appDescription": "RustDesk Server Management",
"appDescription": "BetterDesk Server Management",
"logoType": "icon",
"logoIcon": "dns",
"logoSvg": "",
+1 -1
View File
@@ -3,7 +3,7 @@
"type": "betterdesk-theme",
"branding": {
"appName": "BetterDesk",
"appDescription": "RustDesk Server Management",
"appDescription": "BetterDesk Server Management",
"logoType": "icon",
"logoIcon": "dns",
"logoSvg": "",
+1 -1
View File
@@ -212,7 +212,7 @@
<div class="form-group">
<label class="form-label" for="brand-description">${_('branding.app_description')}</label>
<input type="text" id="brand-description" class="form-input" maxlength="100"
placeholder="RustDesk Server Management">
placeholder="BetterDesk Server Management">
</div>
</form>
</div>