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.
This commit is contained in:
UNITRONIX
2026-08-06 21:40:35 +02:00
parent b41b000b0c
commit a6d7603e59
8 changed files with 501 additions and 56 deletions
+3
View File
@@ -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.
+179 -33
View File
@@ -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{
@@ -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)
}
}
+22
View File
@@ -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
+48 -5
View File
@@ -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_<id>") 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),
}
+30
View File
@@ -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);
+7
View File
@@ -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();
+59 -15
View File
@@ -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 `
<tr data-id="${escapeAttr(rowId)}" data-source="${escapeAttr(source)}">
<td class="device-id-cell">${escapeHtml(reg.device_id)}</td>
<td class="device-id-cell">
<div class="device-id">
<span class="device-id-text">${escapeHtml(reg.device_id)}</span>
<button type="button" class="copy-btn" title="${escapeAttr(_('actions.copy'))}" data-copy="${escapeAttr(reg.device_id || '')}">
<span class="material-icons">content_copy</span>
</button>
</div>
</td>
<td>${escapeHtml(reg.hostname || '—')}</td>
<td class="col-platform">
<div class="platform-cell">
@@ -221,6 +239,31 @@
</tr>
`;
}).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) {