diff --git a/src/Api.ps1 b/src/Api.ps1 index 5873ad3..9d62e3c 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -70,6 +70,18 @@ function Invoke-ApiHandler { return Get-UserMemberOfEndpoint -UserId $matches[1] } + if ($Method -eq "GET" -and $Path -eq "/api/devices") { + return Search-DevicesEndpoint -Query $Query + } + + if ($Method -eq "GET" -and $Path -match "^/api/devices/([^/]+)$") { + return Get-DeviceEndpoint -DeviceId $matches[1] + } + + if ($Method -eq "POST" -and $Path -match "^/api/devices/([^/]+)/action$") { + return Invoke-DeviceActionEndpoint -DeviceId $matches[1] -Body $Body + } + if ($Method -eq "GET" -and $Path -match "^/api/apps/([^/]+)/details$") { return Get-AppDetailsEndpoint -AppId $matches[1] } @@ -1520,6 +1532,107 @@ function Get-UserMemberOfEndpoint { return @{ groups = $groups; count = $groups.Count } } +# ============================================================ +# Devices +# ============================================================ + +function Search-DevicesEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + + $q = [string]$Query.q + $os = [string]$Query.os + $compliance = [string]$Query.compliance + $top = 100 + + $filters = @() + if ($q -and $q.Length -ge 2) { + $qEnc = [uri]::EscapeDataString($q) + $filters += "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(serialNumber,'$q'))" + } + if ($os) { $filters += "operatingSystem eq '$os'" } + if ($compliance) { $filters += "complianceState eq '$compliance'" } + + $filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' } + $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime' + $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" + + $items = @() + $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET + foreach ($d in $resp.value) { + $items += [pscustomobject]@{ + Id = $d.id + DeviceName = $d.deviceName + UserDisplayName = $d.userDisplayName + UserPrincipalName= $d.userPrincipalName + OS = $d.operatingSystem + OSVersion = $d.osVersion + ComplianceState = $d.complianceState + LastSync = $d.lastSyncDateTime + ManagementState = $d.managementState + SerialNumber = $d.serialNumber + Model = $d.model + Manufacturer = $d.manufacturer + EnrolledDateTime = $d.enrolledDateTime + } + } + return @{ items = $items; count = $items.Count } +} + +function Get-DeviceEndpoint { + param($DeviceId) + $err = Test-Connected + if ($err) { return $err } + + $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,totalStorageSpaceInBytes,freeStorageSpaceInBytes' + $d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET + + $totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null } + $freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null } + + return @{ + Id = $d.id + DeviceName = $d.deviceName + UserDisplayName = $d.userDisplayName + UserPrincipalName= $d.userPrincipalName + OS = $d.operatingSystem + OSVersion = $d.osVersion + ComplianceState = $d.complianceState + LastSync = $d.lastSyncDateTime + ManagementState = $d.managementState + SerialNumber = $d.serialNumber + Model = $d.model + Manufacturer = $d.manufacturer + EnrolledDateTime = $d.enrolledDateTime + Imei = $d.imei + WiFiMac = $d.wiFiMacAddress + AzureADDeviceId = $d.azureADDeviceId + JoinType = $d.joinType + EnrollmentType = $d.deviceEnrollmentType + OwnerType = $d.managedDeviceOwnerType + TotalStorageGB = $totalGB + FreeStorageGB = $freeGB + } +} + +function Invoke-DeviceActionEndpoint { + param($DeviceId, $Body) + $err = Test-Connected + if ($err) { return $err } + + $action = [string]$Body.action + $allowed = @('syncDevice','rebootNow','remoteLock','collectDiagnostics','rotateBitLockerKeys','retire') + if ($action -notin $allowed) { + return @{ statusCode = 400; error = "Unbekannte Aktion: $action" } + } + + $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId/$action" + $bodyJson = '{}' + Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $bodyJson -ContentType 'application/json' | Out-Null + return @{ success = $true; action = $action } +} + # ============================================================ # Apps # ============================================================ diff --git a/www/app.js b/www/app.js index 44dc220..d72707d 100644 --- a/www/app.js +++ b/www/app.js @@ -4390,6 +4390,7 @@ function switchMainView(view) { const layout = document.getElementById('layout'); const groupView = document.getElementById('groupMgmtView'); const reportView = document.getElementById('appReportView'); + const devView = document.getElementById('devView'); document.querySelectorAll('#topbarNav .nav-tab').forEach(t => { t.classList.toggle('active', t.dataset.view === view); t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false'); @@ -4397,18 +4398,20 @@ function switchMainView(view) { layout .classList.toggle('hidden', view !== 'apps'); groupView .classList.toggle('hidden', view !== 'groups'); reportView.classList.toggle('hidden', view !== 'report'); + devView .classList.toggle('hidden', view !== 'devices'); } document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { btn.addEventListener('click', () => { const view = btn.dataset.view; - if ((view === 'groups' || view === 'report') && !State.connected) { + if ((view === 'groups' || view === 'report' || view === 'devices') && !State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } switchMainView(view); if (view === 'groups') openGroupExportModal(); if (view === 'report' && ReportState.items.length === 0) loadAppReport(); + if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); } }); }); @@ -5222,3 +5225,239 @@ function notifyCsvImportGroupSelected(groupId, groupName) { if (CsvImport.resolved.length > 0) renderCsvPreview(); else updateCsvImportNote(); } + +// ============================================================ +// Geräte-Tab +// ============================================================ +const DevState = { + items: [], + filtered: [], + selected: null, + sortCol: 'deviceName', + sortAsc: true, + loaded: false +}; + +async function devLoadAll() { + if (DevState.loaded) return; + devShowLoading(true); + try { + const data = await api('/api/devices'); + DevState.items = data.items || []; + DevState.loaded = true; + devApplyFilters(); + } catch (e) { + toast('Geräte konnten nicht geladen werden: ' + e, 'error'); + } finally { + devShowLoading(false); + } +} + +function devShowLoading(on) { + document.getElementById('devLoading').classList.toggle('hidden', !on); + document.getElementById('devEmpty').classList.add('hidden'); +} + +function devApplyFilters() { + const q = document.getElementById('devSearch').value.toLowerCase(); + const os = document.getElementById('devOsFilter').value; + const cmp = document.getElementById('devComplianceFilter').value; + + DevState.filtered = DevState.items.filter(d => { + if (os && d.OS !== os) return false; + if (cmp && d.ComplianceState !== cmp) return false; + if (q) { + const hay = `${d.DeviceName} ${d.UserDisplayName} ${d.UserPrincipalName} ${d.SerialNumber}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + + devSort(); +} + +function devSort() { + const col = DevState.sortCol; + const asc = DevState.sortAsc; + DevState.filtered.sort((a, b) => { + const av = (a[col] || '').toString().toLowerCase(); + const bv = (b[col] || '').toString().toLowerCase(); + return asc ? av.localeCompare(bv) : bv.localeCompare(av); + }); + devRender(); +} + +function devRender() { + const tbody = document.getElementById('devTableBody'); + const empty = document.getElementById('devEmpty'); + document.getElementById('devCount').textContent = DevState.filtered.length; + + if (DevState.filtered.length === 0) { + tbody.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + + // Sort-Icons + document.querySelectorAll('#devTable th.sortable').forEach(th => { + const ico = th.querySelector('.sort-ico'); + if (th.dataset.col === DevState.sortCol) { + ico.textContent = DevState.sortAsc ? ' ▲' : ' ▼'; + } else { + ico.textContent = ''; + } + }); + + let html = ''; + for (const d of DevState.filtered) { + const sync = d.LastSync ? new Date(d.LastSync).toLocaleDateString('de-DE') : '—'; + const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok' + : d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk'; + const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : ''; + html += ` + ${escapeHtml(d.DeviceName || '—')} + ${escapeHtml(d.UserDisplayName || '—')} + ${escapeHtml(d.OS || '—')} + ${escapeHtml(d.ComplianceState || '—')} + ${sync} + `; + } + tbody.innerHTML = html; + + tbody.querySelectorAll('tr').forEach(tr => { + tr.addEventListener('click', () => { + const id = tr.dataset.id; + const device = DevState.filtered.find(d => d.Id === id); + if (device) devSelectDevice(device); + }); + }); +} + +async function devSelectDevice(device) { + DevState.selected = device; + // Auswahl visuell + document.querySelectorAll('#devTableBody tr').forEach(tr => + tr.classList.toggle('dev-row-selected', tr.dataset.id === device.Id)); + + // Detail-Panel anzeigen mit Basis-Infos sofort + devRenderDetail(device); + + // Vollständige Details nachladen + try { + const full = await api(`/api/devices/${encodeURIComponent(device.Id)}`); + devRenderDetail(full); + } catch (e) { + // Basis-Infos bleiben + } +} + +function devRenderDetail(d) { + const panel = document.getElementById('devDetail'); + panel.classList.remove('hidden'); + + document.getElementById('devDetailName').textContent = d.DeviceName || '—'; + document.getElementById('devDetailSub').textContent = `${d.Manufacturer || ''} ${d.Model || ''}`.trim() || '—'; + + const sync = d.LastSync ? new Date(d.LastSync).toLocaleString('de-DE') : '—'; + const enrolled = d.EnrolledDateTime ? new Date(d.EnrolledDateTime).toLocaleDateString('de-DE') : '—'; + const storage = (d.TotalStorageGB != null) + ? `${d.FreeStorageGB} GB frei / ${d.TotalStorageGB} GB gesamt` : null; + + const rows = [ + ['Benutzer', d.UserDisplayName], + ['UPN', d.UserPrincipalName], + ['OS', d.OS ? `${d.OS} ${d.OSVersion || ''}`.trim() : null], + ['Compliance', d.ComplianceState], + ['Seriennummer', d.SerialNumber], + ['IMEI', d.Imei], + ['Wi-Fi MAC', d.WiFiMac], + ['Speicher', storage], + ['Eingeschrieben', enrolled], + ['Letzter Sync', sync], + ['Join-Typ', d.JoinType], + ['Besitzertyp', d.OwnerType], + ['Azure AD ID', d.AzureADDeviceId], + ]; + + let html = ''; + for (const [label, val] of rows) { + if (!val) continue; + html += `
${escapeHtml(label)}
${escapeHtml(String(val))}
`; + } + document.getElementById('devInfoGrid').innerHTML = html; + + // Aktionen: BitLocker nur für Windows + const isWin = (d.OS || '').toLowerCase() === 'windows'; + const bitlockerBtn = document.getElementById('devBtnBitlocker'); + if (bitlockerBtn) bitlockerBtn.style.display = isWin ? '' : 'none'; +} + +// Aktion ausführen +document.getElementById('devActions')?.addEventListener('click', async (e) => { + const btn = e.target.closest('.dev-action-btn'); + if (!btn || !DevState.selected) return; + const action = btn.dataset.action; + if (!action) return; + + const labels = { + syncDevice: 'Sync', + rebootNow: 'Neustart', + remoteLock: 'Sperren', + collectDiagnostics: 'Diagnose sammeln', + rotateBitLockerKeys: 'BitLocker-Schlüssel rotieren', + retire: 'Retire (MDM entfernen)' + }; + const isDangerous = action === 'retire'; + const devName = DevState.selected.DeviceName || DevState.selected.Id; + const msg = isDangerous + ? `ACHTUNG: "${labels[action]}" auf "${devName}" ausführen?\n\nDas MDM-Profil wird entfernt — Gerät wird nicht mehr verwaltet.` + : `"${labels[action]}" auf "${devName}" ausführen?`; + + if (!confirm(msg)) return; + + btn.disabled = true; + const origText = btn.textContent.trim(); + btn.textContent = '…'; + try { + await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, { + method: 'POST', + body: JSON.stringify({ action }) + }); + toast(`${labels[action]}: Aktion wurde gesendet`, 'ok'); + if (action === 'retire') { + // Gerät aus Liste entfernen + DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id); + DevState.selected = null; + document.getElementById('devDetail').classList.add('hidden'); + devApplyFilters(); + } + } catch (e) { + toast(`Fehler bei ${labels[action]}: ${e}`, 'error'); + } finally { + btn.disabled = false; + btn.textContent = origText; + } +}); + +// Such- und Filter-Events +let devSearchDebounce = null; +document.getElementById('devSearch').addEventListener('input', () => { + clearTimeout(devSearchDebounce); + devSearchDebounce = setTimeout(devApplyFilters, 250); +}); +document.getElementById('devOsFilter').addEventListener('change', devApplyFilters); +document.getElementById('devComplianceFilter').addEventListener('change', devApplyFilters); + +// Sortierung +document.querySelectorAll('#devTable th.sortable').forEach(th => { + th.addEventListener('click', () => { + if (DevState.sortCol === th.dataset.col) { + DevState.sortAsc = !DevState.sortAsc; + } else { + DevState.sortCol = th.dataset.col; + DevState.sortAsc = true; + } + devSort(); + }); +}); diff --git a/www/index.html b/www/index.html index 78f4b98..59b2177 100644 --- a/www/index.html +++ b/www/index.html @@ -35,6 +35,7 @@ +
@@ -463,6 +464,92 @@
+ + +
diff --git a/www/styles.css b/www/styles.css index 22dc30a..facd430 100644 --- a/www/styles.css +++ b/www/styles.css @@ -5195,3 +5195,129 @@ body.read-only .app-row { .csv-row-done { background: rgba(74,222,128,.05); } .csv-row-warn { background: rgba(250,204,21,.06); opacity: 0.85; } + +/* ============================================================ + Geräte-View + ============================================================ */ +.dev-view { + display: flex; + flex-direction: column; + height: calc(100vh - 52px); + overflow: hidden; +} + +.dev-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--hairline-soft); + flex-shrink: 0; +} +.dev-toolbar .search-wrap { flex: 1; max-width: 380px; } +.input-select { + padding: 5px 8px; + border: 1px solid var(--hairline); + border-radius: 6px; + background: var(--bg2); + color: var(--text); + font-size: 12.5px; + cursor: pointer; +} + +.dev-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Tabelle */ +.dev-list-wrap { + flex: 1; + overflow-y: auto; + border-right: 1px solid var(--hairline-soft); +} +.dev-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.dev-table thead th { + position: sticky; + top: 0; + background: var(--bg2); + padding: 8px 12px; + text-align: left; + font-weight: 600; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--hairline-soft); + white-space: nowrap; + user-select: none; +} +.dev-table thead th.sortable { cursor: pointer; } +.dev-table thead th.sortable:hover { color: var(--text); } +.dev-table tbody tr { + cursor: pointer; + border-bottom: 1px solid var(--hairline-soft); + transition: background 0.1s; +} +.dev-table tbody tr:hover { background: var(--bg2); } +.dev-table tbody tr.dev-row-selected { background: var(--accent-faint, rgba(59,130,246,.12)); } +.dev-table tbody td { + padding: 7px 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.dev-empty, .dev-loading { + padding: 32px; + text-align: center; + color: var(--muted); + font-size: 13px; +} +.dev-loading { display: flex; align-items: center; justify-content: center; gap: 10px; } + +.dev-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 10px; +} +.dev-badge-ok { background: rgba(74,222,128,.15); color: #4ade80; } +.dev-badge-err { background: rgba(248,113,113,.15); color: #f87171; } +.dev-badge-unk { background: var(--hairline-soft); color: var(--muted); } + +/* Detail-Panel */ +.dev-detail { + width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + background: var(--bg2); +} +.dev-detail-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--hairline-soft); +} +.dev-detail-name { font-weight: 700; font-size: 15px; } +.dev-detail-sub { font-size: 12px; color: var(--muted); margin-top: 2px; } + +.dev-detail-body { padding: 12px 16px; flex: 1; } +.dev-info-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + font-size: 12.5px; +} +.dev-info-grid dt { color: var(--muted); white-space: nowrap; } +.dev-info-grid dd { margin: 0; word-break: break-all; } + +.dev-actions { padding: 12px 16px; border-top: 1px solid var(--hairline-soft); } +.dev-actions-title { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .04em; } +.dev-action-btns { display: flex; flex-wrap: wrap; gap: 6px; } +.dev-action-btn { display: inline-flex; align-items: center; gap: 5px; }