From a6d7603e590558652dc03eb2df4be4bdfd7ff528 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:40:35 +0200 Subject: [PATCH] fix(enrollment): harden Reject & Ban re-enroll and QA follow-ups (#351) Hard-delete enrollment audit peers on Allow re-enroll/Unban so managed mode re-queues instead of bypassing approval; fix All filter, devices ?search=, orphan rejects, metadata enrich, and copy ID. --- CHANGELOG.md | 3 + betterdesk-server/api/branding_handlers.go | 212 +++++++++++++++--- .../api/enrollment_history_test.go | 156 ++++++++++++- betterdesk-server/api/server.go | 22 ++ betterdesk-server/signal/handler.go | 53 ++++- web-nodejs/public/css/registrations.css | 30 +++ web-nodejs/public/js/devices.js | 7 + web-nodejs/public/js/registrations.js | 74 ++++-- 8 files changed, 501 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8958f95..64ade309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +### Fixed +- **Enrollment QA follow-up (#351):** Enrollment Requests **All** filter aggregates pending + approved + rejected Go history. Reject & Ban → **Allow re-enroll** / Unban hard-deletes the enrollment audit peer so managed mode re-queues instead of leaving a zombie or bypassing approval. Orphan legacy `rejected_device_*` locks appear under Rejected. Devices `?search=` is applied on load (View device). Pending metadata can be enriched when HTTP enrollment supplies hostname/platform/version after a signal queue. Copy Device ID on the registrations table. Ships via panel update (Go API/signal restart). + ### Changed - **Devices drag & drop:** while dragging a device row onto folder/group chips, the panel content (`.main-content` / UX 3.5) auto-scrolls when the pointer nears the top or bottom edge. diff --git a/betterdesk-server/api/branding_handlers.go b/betterdesk-server/api/branding_handlers.go index a078a72f..44e99b76 100644 --- a/betterdesk-server/api/branding_handlers.go +++ b/betterdesk-server/api/branding_handlers.go @@ -651,6 +651,49 @@ func (s *Server) clearEnrollmentRejectionState(deviceID string) { } } +// removeEnrollmentRejectAuditPeer permanently removes a peer that existed only +// so Reject & Ban could show under Devices → Banned. Leaving the row after +// clear/unban would make checkEnrollmentPermission treat the device as already +// enrolled and bypass managed pending approval (#351). +func (s *Server) removeEnrollmentRejectAuditPeer(deviceID string) error { + if deviceID == "" || s.db == nil { + return nil + } + if s.peers != nil { + s.peers.Remove(deviceID) + } + _ = s.db.DeleteConfig(bdMgmtPublicKeyConfigPref + deviceID) + _ = s.db.DeleteConfig(deviceBundleIDPrefix + deviceID) + if err := s.db.HardDeletePeer(deviceID); err != nil { + log.Printf("[API] removeEnrollmentRejectAuditPeer: HardDeletePeer %s: %v", deviceID, err) + return err + } + return nil +} + +// enrollmentRejectAuditPeer reports whether clearing rejection / unban should +// remove the peers row so the device must re-enter the pending queue. +func enrollmentRejectAuditPeer(peerRow *db.Peer, rejectedRaw, decisionRaw string) bool { + if peerRow != nil && peerRow.BanReason == enrollmentRejectBanReason { + return true + } + if decisionRaw != "" { + var d enrollmentDecision + if json.Unmarshal([]byte(decisionRaw), &d) == nil && d.Status == "rejected" && d.Banned { + return true + } + } + if rejectedRaw != "" { + var meta struct { + Banned bool `json:"banned"` + } + if json.Unmarshal([]byte(rejectedRaw), &meta) == nil && meta.Banned { + return true + } + } + return false +} + // handleListPendingDevices returns all pending enrollment requests. // GET /api/enrollment/pending func (s *Server) handleListPendingDevices(w http.ResponseWriter, r *http.Request) { @@ -688,20 +731,76 @@ func (s *Server) handleListEnrollmentHistory(w http.ResponseWriter, r *http.Requ func (s *Server) listEnrollmentHistory(statusFilter string) []enrollmentDecision { var result []enrollmentDecision + seen := make(map[string]struct{}) configs, err := s.db.ListConfigByPrefix(enrollmentDecisionPrefix) if err != nil { log.Printf("[API] listEnrollmentHistory: %v", err) - return result + } else { + for _, cfg := range configs { + var d enrollmentDecision + if json.Unmarshal([]byte(cfg.Value), &d) != nil || d.DeviceID == "" { + continue + } + if statusFilter != "" && d.Status != statusFilter { + continue + } + seen[d.DeviceID] = struct{}{} + result = append(result, d) + } } - for _, cfg := range configs { - var d enrollmentDecision - if json.Unmarshal([]byte(cfg.Value), &d) != nil || d.DeviceID == "" { - continue + + // Legacy builds stored rejected_device_* without enrollment_decision_*. + // Surface those orphans under Rejected (and All) so operators can Allow re-enroll (#351). + if statusFilter == "" || statusFilter == "rejected" { + rejectedConfigs, rerr := s.db.ListConfigByPrefix(rejectedDevicePrefix) + if rerr != nil { + log.Printf("[API] listEnrollmentHistory orphans: %v", rerr) + return result } - if statusFilter != "" && d.Status != statusFilter { - continue + for _, cfg := range rejectedConfigs { + deviceID := strings.TrimPrefix(cfg.Key, rejectedDevicePrefix) + if deviceID == "" { + continue + } + if _, ok := seen[deviceID]; ok { + continue + } + d := enrollmentDecision{ + DeviceID: deviceID, + Status: "rejected", + DecidedAt: timeNowISO(), + } + var meta pendingEnrollmentMeta + if json.Unmarshal([]byte(cfg.Value), &meta) == nil { + if meta.DeviceID != "" { + d.DeviceID = meta.DeviceID + } + d.UUID = meta.UUID + d.Hostname = meta.Hostname + d.Platform = meta.Platform + d.Version = meta.Version + d.DeviceType = meta.DeviceType + d.BundleID = meta.BundleID + d.Tags = meta.Tags + d.IP = meta.IP + d.CreatedAt = meta.CreatedAt + if meta.CreatedAt != "" { + d.DecidedAt = meta.CreatedAt + } + } + var bannedMeta struct { + Banned bool `json:"banned"` + DecidedAt string `json:"decided_at"` + } + if json.Unmarshal([]byte(cfg.Value), &bannedMeta) == nil { + d.Banned = bannedMeta.Banned + if bannedMeta.DecidedAt != "" { + d.DecidedAt = bannedMeta.DecidedAt + } + } + seen[d.DeviceID] = struct{}{} + result = append(result, d) } - result = append(result, d) } return result } @@ -937,7 +1036,8 @@ func (s *Server) handleRejectDevice(w http.ResponseWriter, r *http.Request) { } // handleClearEnrollmentRejection removes the rejection lock so the device can -// re-enter the pending queue. Also unbans when the ban reason is enrollment reject. +// re-enter the pending queue. Enrollment-reject audit peers are hard-deleted +// (not merely unbanned) so managed mode cannot treat them as already enrolled. // POST /api/enrollment/clear-rejection/{id} func (s *Server) handleClearEnrollmentRejection(w http.ResponseWriter, r *http.Request) { deviceID := r.PathValue("id") @@ -953,29 +1053,28 @@ func (s *Server) handleClearEnrollmentRejection(w http.ResponseWriter, r *http.R return } - unbanned := false - if peerRow, err := s.db.GetPeer(deviceID); err == nil && peerRow != nil && peerRow.Banned { - if peerRow.BanReason == enrollmentRejectBanReason { - if err := s.db.UnbanPeer(deviceID); err != nil { - log.Printf("[API] handleClearEnrollmentRejection: UnbanPeer %s: %v", deviceID, err) - } else { - unbanned = true - if s.peers != nil { - if entry := s.peers.Get(deviceID); entry != nil { - entry.Banned = false - } - } - } - } - } + peerRow, _ := s.db.GetPeer(deviceID) + removeAudit := enrollmentRejectAuditPeer(peerRow, rejected, decisionRaw) s.clearEnrollmentRejectionState(deviceID) + unbanned := false + peerRemoved := false + if removeAudit { + if err := s.removeEnrollmentRejectAuditPeer(deviceID); err != nil { + log.Printf("[API] handleClearEnrollmentRejection: remove audit peer %s: %v", deviceID, err) + } else { + unbanned = true + peerRemoved = true + } + } + actor := getUsernameFromCtx(r) if s.auditLog != nil { s.auditLog.Log("enrollment_rejection_cleared", s.remoteIP(r), actor, map[string]string{ - "device_id": deviceID, - "unbanned": strconv.FormatBool(unbanned), + "device_id": deviceID, + "unbanned": strconv.FormatBool(unbanned), + "peer_removed": strconv.FormatBool(peerRemoved), }) } @@ -983,17 +1082,19 @@ func (s *Server) handleClearEnrollmentRejection(w http.ResponseWriter, r *http.R s.eventBus.Publish(events.Event{ Type: "enrollment_rejection_cleared", Data: map[string]string{ - "device_id": deviceID, - "unbanned": strconv.FormatBool(unbanned), + "device_id": deviceID, + "unbanned": strconv.FormatBool(unbanned), + "peer_removed": strconv.FormatBool(peerRemoved), }, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "device_id": deviceID, - "unbanned": unbanned, + "success": true, + "device_id": deviceID, + "unbanned": unbanned, + "peer_removed": peerRemoved, }) } @@ -1148,7 +1249,8 @@ func isSupportAgentEnrollment(req *EnrollmentRequest) bool { // storePendingDevice keeps the first identity submission immutable. Otherwise // a later unauthenticated retry could replace the public key used to recover a -// token after operator approval. +// token after operator approval. Empty display metadata from an earlier signal +// queue may be enriched when a later HTTP enrollment supplies hostname/os/version. func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) error { if raw, err := s.db.GetConfig(pendingDevicePrefix + req.DeviceID); err == nil && raw != "" { existing := parsePendingEnrollmentMeta(raw) @@ -1169,7 +1271,51 @@ func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) err normalizeEnrollmentBundleID(existing.BundleID) != normalizeEnrollmentBundleID(req.BundleID) { return fmt.Errorf("pending device bundle ID does not match") } - return nil + changed := false + if existing.Hostname == "" && req.Hostname != "" { + existing.Hostname = req.Hostname + changed = true + } + if existing.Platform == "" && req.Platform != "" { + existing.Platform = req.Platform + changed = true + } + if existing.Version == "" && req.Version != "" { + existing.Version = req.Version + changed = true + } + if existing.DeviceType == "" && strings.TrimSpace(req.DeviceType) != "" { + existing.DeviceType = strings.TrimSpace(req.DeviceType) + changed = true + } + if existing.BundleID == "" && normalizeEnrollmentBundleID(req.BundleID) != "" { + existing.BundleID = normalizeEnrollmentBundleID(req.BundleID) + changed = true + } + if existing.Tags == "" && normalizeEnrollmentTags(req.Tags) != "" { + existing.Tags = normalizeEnrollmentTags(req.Tags) + changed = true + } + if existing.PublicKey == "" && req.PublicKey != "" { + existing.PublicKey = req.PublicKey + changed = true + } + if existing.UUID == "" && req.UUID != "" { + existing.UUID = req.UUID + changed = true + } + if existing.IP == "" && clientIP != "" { + existing.IP = clientIP + changed = true + } + if !changed { + return nil + } + data, mErr := json.Marshal(existing) + if mErr != nil { + return mErr + } + return s.db.SetConfig(pendingDevicePrefix+req.DeviceID, string(data)) } info := pendingDeviceInfo{ diff --git a/betterdesk-server/api/enrollment_history_test.go b/betterdesk-server/api/enrollment_history_test.go index 64d5b511..72e25e79 100644 --- a/betterdesk-server/api/enrollment_history_test.go +++ b/betterdesk-server/api/enrollment_history_test.go @@ -116,6 +116,17 @@ func TestEnrollmentClearRejectionAllowsRequeue(t *testing.T) { t.Fatalf("clear: expected 200, got %d body=%s", rec.Code, rec.Body.String()) } + var clearResp struct { + Unbanned bool `json:"unbanned"` + PeerRemoved bool `json:"peer_removed"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &clearResp); err != nil { + t.Fatal(err) + } + if !clearResp.Unbanned || !clearResp.PeerRemoved { + t.Fatalf("clear response: unbanned=%v peer_removed=%v", clearResp.Unbanned, clearResp.PeerRemoved) + } + rejected, _ := database.GetConfig(rejectedDevicePrefix + deviceID) if rejected != "" { t.Fatal("rejected_device should be cleared") @@ -129,11 +140,150 @@ func TestEnrollmentClearRejectionAllowsRequeue(t *testing.T) { if err != nil { t.Fatalf("GetPeer: %v", err) } - if p == nil { - t.Fatal("peer should still exist after clear") + if p != nil { + t.Fatal("enrollment-reject audit peer must be hard-deleted so managed mode re-queues") + } +} + +func TestEnrollmentClearRejectionWithoutBanKeepsNoPeer(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "ENR-CLR2" + seedPendingEnrollment(t, database, deviceID) + + cfg := config.DefaultConfig() + srv := New(cfg, database, peer.NewMap(), nil, "test") + mux := http.NewServeMux() + mux.HandleFunc("POST /api/enrollment/reject/{id}", srv.handleRejectDevice) + mux.HandleFunc("POST /api/enrollment/clear-rejection/{id}", srv.handleClearEnrollmentRejection) + + req := httptest.NewRequest(http.MethodPost, "/api/enrollment/reject/"+deviceID, bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("reject: %d %s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/enrollment/clear-rejection/"+deviceID, nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("clear: %d %s", rec.Code, rec.Body.String()) + } + + p, _ := database.GetPeer(deviceID) + if p != nil { + t.Fatal("reject without ban should not create a peer") + } + rejected, _ := database.GetConfig(rejectedDevicePrefix + deviceID) + if rejected != "" { + t.Fatal("rejection should be cleared") + } +} + +func TestEnrollmentUnbanRemovesAuditPeer(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "ENR-UNBAN1" + seedPendingEnrollment(t, database, deviceID) + + cfg := config.DefaultConfig() + srv := New(cfg, database, peer.NewMap(), nil, "test") + mux := http.NewServeMux() + mux.HandleFunc("POST /api/enrollment/reject/{id}", srv.handleRejectDevice) + mux.HandleFunc("POST /api/peers/{id}/unban", srv.handleUnbanPeer) + + body, _ := json.Marshal(map[string]any{"ban": true}) + req := httptest.NewRequest(http.MethodPost, "/api/enrollment/reject/"+deviceID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("reject: %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/unban", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unban: %d %s", rec.Code, rec.Body.String()) + } + + p, _ := database.GetPeer(deviceID) + if p != nil { + t.Fatal("unban of enrollment-reject peer must hard-delete audit row") + } + rejected, _ := database.GetConfig(rejectedDevicePrefix + deviceID) + if rejected != "" { + t.Fatal("unban should clear rejected_device") + } +} + +func TestEnrollmentManualBanUnbanKeepsPeer(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "ENR-MANBAN1" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, Hostname: "keep-me", Status: "OFFLINE"}); err != nil { + t.Fatal(err) + } + if err := database.BanPeer(deviceID, "manual panel ban"); err != nil { + t.Fatal(err) + } + + cfg := config.DefaultConfig() + srv := New(cfg, database, peer.NewMap(), nil, "test") + mux := http.NewServeMux() + mux.HandleFunc("POST /api/peers/{id}/unban", srv.handleUnbanPeer) + + req := httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/unban", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unban: %d %s", rec.Code, rec.Body.String()) + } + + p, err := database.GetPeer(deviceID) + if err != nil || p == nil { + t.Fatal("manual ban unban must keep peer row") } if p.Banned { - t.Fatal("peer should be unbanned after clear-rejection") + t.Fatal("peer should be unbanned") + } +} + +func TestEnrollmentHistoryIncludesOrphanRejected(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "ENR-ORPHAN1" + if err := database.SetConfig(rejectedDevicePrefix+deviceID, `{"rejected":true,"device_id":"`+deviceID+`","hostname":"old-host","banned":false}`); err != nil { + t.Fatal(err) + } + + cfg := config.DefaultConfig() + srv := New(cfg, database, peer.NewMap(), nil, "test") + mux := http.NewServeMux() + mux.HandleFunc("GET /api/enrollment/history", srv.handleListEnrollmentHistory) + + req := httptest.NewRequest(http.MethodGet, "/api/enrollment/history?status=rejected", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("history: %d", rec.Code) + } + var hist struct { + Devices []enrollmentDecision `json:"devices"` + Count int `json:"count"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil { + t.Fatal(err) + } + if hist.Count != 1 || hist.Devices[0].DeviceID != deviceID || hist.Devices[0].Hostname != "old-host" { + t.Fatalf("expected orphan rejected row, got %+v", hist) } } diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index b85b1662..bec82aba 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -1259,6 +1259,28 @@ func (s *Server) handleUnbanPeer(w http.ResponseWriter, r *http.Request) { if !s.peerOrgScopeCheck(w, r, id) { return } + + peerRow, _ := s.db.GetPeer(id) + enrollmentRejectBan := peerRow != nil && peerRow.BanReason == enrollmentRejectBanReason + + // Enrollment Reject & Ban created an audit-only peer. Unban must remove it + // so managed mode re-queues for approval instead of treating the ID as enrolled (#351). + if enrollmentRejectBan { + s.clearEnrollmentRejectionState(id) + if err := s.removeEnrollmentRejectAuditPeer(id); err != nil { + writeInternalError(w, err, "removeEnrollmentRejectAuditPeer") + return + } + if s.auditLog != nil { + s.auditLog.Log(audit.ActionPeerUnbanned, s.remoteIP(r), id, map[string]string{ + "peer_removed": "true", + "reason": enrollmentRejectBanReason, + }) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned", "id": id, "peer_removed": "true"}) + return + } + if err := s.db.UnbanPeer(id); err != nil { writeInternalError(w, err, "UnbanPeer") return diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index 0034400a..271f63f1 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -2389,7 +2389,7 @@ func (s *Server) checkEnrollmentPermission(peerID, clientIP string) bool { // In managed mode, unknown devices are placed into the pending // enrollment queue so an operator can review and approve/reject them. // The connection is still denied until approval. - s.recordPendingEnrollment(peerID, clientIP) + s.recordPendingEnrollment(peerID, clientIP, pendingEnrollmentMeta{}) log.Printf("[signal] Enrollment: queued unknown peer %s for approval (managed mode)", peerID) return false } @@ -2421,11 +2421,20 @@ type pendingEnrollmentInfo struct { CreatedAt string `json:"created_at"` } +// pendingEnrollmentMeta carries optional display fields when available. +// Stock RustDesk RegisterPeer/RegisterPk do not include these; a later HTTP +// enrollment or enriching call may supply them (#351). +type pendingEnrollmentMeta struct { + Hostname string + Platform string + Version string +} + // recordPendingEnrollment stores an unknown peer in the pending enrollment // queue (server_config key "pending_device_") so operators can review it. -// It is idempotent: existing pending entries are preserved (to keep their -// original timestamp) and already-rejected devices are never re-queued. -func (s *Server) recordPendingEnrollment(peerID, clientIP string) { +// Existing created_at is preserved; empty hostname/platform/version may be +// filled when later metadata arrives. Already-rejected devices are never re-queued. +func (s *Server) recordPendingEnrollment(peerID, clientIP string, meta pendingEnrollmentMeta) { if s.db == nil { return } @@ -2435,14 +2444,48 @@ func (s *Server) recordPendingEnrollment(peerID, clientIP string) { return } - // Preserve an existing pending entry (keeps the original created_at). key := "pending_device_" + peerID if v, err := s.db.GetConfig(key); err == nil && v != "" { + var existing pendingEnrollmentInfo + if json.Unmarshal([]byte(v), &existing) != nil { + return + } + changed := false + if existing.Hostname == "" && meta.Hostname != "" { + existing.Hostname = meta.Hostname + changed = true + } + if existing.Platform == "" && meta.Platform != "" { + existing.Platform = meta.Platform + changed = true + } + if existing.Version == "" && meta.Version != "" { + existing.Version = meta.Version + changed = true + } + if existing.IP == "" && clientIP != "" { + existing.IP = clientIP + changed = true + } + if !changed { + return + } + data, mErr := json.Marshal(existing) + if mErr != nil { + log.Printf("[signal] recordPendingEnrollment: marshal enrich failed for %s: %v", peerID, mErr) + return + } + if err := s.db.SetConfig(key, string(data)); err != nil { + log.Printf("[signal] recordPendingEnrollment: enrich store failed for %s: %v", peerID, err) + } return } info := pendingEnrollmentInfo{ DeviceID: peerID, + Hostname: meta.Hostname, + Platform: meta.Platform, + Version: meta.Version, IP: clientIP, CreatedAt: time.Now().UTC().Format(time.RFC3339), } diff --git a/web-nodejs/public/css/registrations.css b/web-nodejs/public/css/registrations.css index 3b94d782..f6d6497f 100644 --- a/web-nodejs/public/css/registrations.css +++ b/web-nodejs/public/css/registrations.css @@ -287,6 +287,36 @@ color: var(--primary); } +.device-id-cell .device-id { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.device-id-cell .copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: 4px; + opacity: 0.55; +} + +.device-id-cell .copy-btn .material-icons { + font-size: 1rem; +} + +.device-id-cell .device-id:hover .copy-btn, +.device-id-cell .copy-btn:hover, +.device-id-cell .copy-btn.copied { + opacity: 1; + color: var(--primary); +} + /* Time column */ .time-cell { color: var(--text-secondary); diff --git a/web-nodejs/public/js/devices.js b/web-nodejs/public/js/devices.js index 385c6059..e5ba5130 100644 --- a/web-nodejs/public/js/devices.js +++ b/web-nodejs/public/js/devices.js @@ -2131,6 +2131,13 @@ function initSearch() { const searchInput = document.getElementById('search-input'); if (!searchInput) return; + + // Honor ?search= from Enrollment Requests "View device" links (#351). + const urlSearch = new URLSearchParams(window.location.search).get('search'); + if (urlSearch) { + searchQuery = urlSearch.trim(); + searchInput.value = searchQuery; + } searchInput.addEventListener('input', Utils.debounce((e) => { searchQuery = e.target.value.trim(); diff --git a/web-nodejs/public/js/registrations.js b/web-nodejs/public/js/registrations.js index 52f04464..aaf966bc 100644 --- a/web-nodejs/public/js/registrations.js +++ b/web-nodejs/public/js/registrations.js @@ -88,25 +88,36 @@ `; try { - const enrollmentFetch = shouldLoadEnrollmentPending() - ? apiFetch('/api/enrollment/pending') - : shouldLoadEnrollmentHistory() - ? apiFetch(`/api/enrollment/history?status=${encodeURIComponent(currentStatus)}`) - : Promise.resolve({ success: true, data: [], count: 0 }); + const enrollmentFetches = []; + if (shouldLoadEnrollmentPending()) { + enrollmentFetches.push( + apiFetch('/api/enrollment/pending').then(result => ({ kind: 'pending', result })) + ); + } + if (shouldLoadEnrollmentHistory()) { + const historyUrl = currentStatus + ? `/api/enrollment/history?status=${encodeURIComponent(currentStatus)}` + : '/api/enrollment/history'; + enrollmentFetches.push( + apiFetch(historyUrl).then(result => ({ kind: 'history', result })) + ); + } - const [registrationResult, enrollmentResult] = await Promise.all([ + const [registrationResult, ...enrollmentParts] = await Promise.all([ apiFetch(`/api/registrations?${params}`), - enrollmentFetch, + ...enrollmentFetches, ]); if (!registrationResult.success) throw new Error(registrationResult.error); const registrations = normalizeRegistrations(registrationResult.data || []); - const enrollments = enrollmentResult.success - ? (shouldLoadEnrollmentHistory() - ? normalizeEnrollmentHistory(enrollmentResult.data || []) - : normalizeEnrollments(enrollmentResult.data || []) - ).filter(matchesSearch) - : []; + const enrollments = []; + for (const part of enrollmentParts) { + if (!part.result?.success) continue; + const rows = part.kind === 'history' + ? normalizeEnrollmentHistory(part.result.data || []) + : normalizeEnrollments(part.result.data || []); + enrollments.push(...rows.filter(matchesSearch)); + } renderTable([...enrollments, ...registrations]); } catch (err) { @@ -203,7 +214,14 @@ return ` - ${escapeHtml(reg.device_id)} + +
+ ${escapeHtml(reg.device_id)} + +
+ ${escapeHtml(reg.hostname || '—')}
@@ -221,6 +239,31 @@ `; }).join(''); + + tbody.querySelectorAll('.copy-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.dataset.copy || ''; + if (!id) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(id); + } else { + const ta = document.createElement('textarea'); + ta.value = id; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 2000); + showToast(_('common.copied'), 'success'); + } catch (_) { + showToast(_('common.copy_failed') || _('actions.copy'), 'error'); + } + }); + }); } // ---- Actions ---- @@ -540,7 +583,8 @@ } function shouldLoadEnrollmentHistory() { - return currentStatus === 'approved' || currentStatus === 'rejected'; + // All (empty status) aggregates pending + approved + rejected history (#351). + return !currentStatus || currentStatus === 'approved' || currentStatus === 'rejected'; } function normalizeRegistrations(items) {