13 Commits
Author SHA1 Message Date
marcoandClaude Opus 4.8 32e1506be9 v0.1.36 — Bulk-Gruppenzuweisung für Policies und Autopilot im Offboarding
Build & Release MSI / build-msi (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 12:01:45 +02:00
marcoandClaude Opus 4.8 87fd496ca0 Setup-Skript: DeviceManagementServiceConfig.Read.All bei -IncludeDeviceActions
Der Autopilot-Status im Geräte-Detail (Profil-Zuweisung / letzter Kontakt)
braucht DeviceManagementServiceConfig.Read.All. Das Flag -IncludeDeviceActions
fügt den Scope jetzt hinzu (Read/Write und Read-Only). Ist ohnehin Offboarding
aktiv, wird die ReadWrite-Variante bevorzugt und die Read-Variante entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 11:41:10 +02:00
marcoandClaude Opus 4.8 c10d0b3830 Doku: vollständige Graph-Berechtigungen für den vollen Funktionsumfang
README: Berechtigungstabelle nach Bereich gruppiert (Kern, Geräte-Tab,
Offboarding, Read-Only) inkl. der neuen Abhängigkeiten
DeviceManagementServiceConfig.* (Autopilot-Status im Geräte-Detail,
Autopilot-Suche im Offboarding). App-Registration.md entsprechend ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 11:21:25 +02:00
marcoandClaude Opus 4.8 a78c29744e Offboarding: Autopilot-Geräte mitdurchsuchen
Die Offboarding-Suche fand bisher nur managedDevices (Intune). Reine
Autopilot-Geräte (registriert, aber nicht/nicht mehr in Intune enrolled)
tauchten nie auf. Jetzt wird zusätzlich windowsAutopilotDeviceIdentities
durchsucht (Serial: contains(serialNumber), Name: startswith(displayName),
Fehler still ignoriert) und die Treffer werden — dedupliziert per Serial/
Autopilot-Id — gemergt. Für Autopilot-only-Treffer wird das Entra-Objekt per
azureAdDeviceId nachgeladen. offKey nutzt jetzt eine Fallback-Kette, da
Autopilot-only-Geräte keine intuneDeviceId haben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 11:21:24 +02:00
marcoandClaude Opus 4.8 0f8fc7b159 Doku: Bulk-Gruppenzuweisung und Geräte-Features in README
- Policies-Tab: neuer Abschnitt "Gruppen zuweisen (Bulk)" (Add/Replace,
  Alle Geräte/Benutzer), Tabs-Übersicht und REST-API-Tabelle ergänzt.
- Geräte-Tab: Verwaltungs-Spalte (Intune/Co-Managed) und Autopilot-Status
  im Detail-Panel dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 09:31:20 +02:00
marcoandClaude Opus 4.8 7bfcfc18ad Policies: Bulk-Zuweisung von Gruppen inkl. Alle Geräte/Alle Benutzer
- Neuer Toolbar-Button "Gruppen zuweisen" im Policy-Tab: mehreren
  ausgewählten Policies (typübergreifend) in einem Schritt dieselben
  Include-/Exclude-Gruppen zuweisen.
- Zwei Modi: "Hinzufügen" (bestehende Zuweisungen werden gelesen und
  gemergt, da Graph /assign Full-Replace ist) und "Ersetzen".
- Integrierte Include-Ziele "Alle Geräte" (allDevicesAssignmentTarget)
  und "Alle Benutzer" (allLicensedUsersAssignmentTarget), dedupliziert
  und mit Exclude-Gruppen kombinierbar.
- Invoke-PolicyAssignEndpoint um mode/allDevices/allUsers erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 09:18:55 +02:00
marcoandClaude Opus 4.8 af301925e9 v0.1.35 — Settings-Catalog-Import-Fix und Update-Check hinter Proxy
Build & Release MSI / build-msi (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:50:27 +02:00
marcoandClaude Opus 4.8 0ae20211c7 Settings-Catalog-Import: choiceSettingCollectionValue als Array erzwingen
choiceSettingCollectionValue fehlte in der Array-Key-Liste von
Repair-SettingsCatalogArrays. Beim PS-5.1-JSON-Roundtrip wurde ein
Ein-Element-choiceSettingCollectionValue zum Einzelobjekt entpackt, worauf
Graph den Import mit 400 "Property choiceSettingCollectionValue in payload has
a value that does not match schema" ablehnte (z. B. Default Defender Antivirus
Policy). Jetzt analog zu group-/simpleSettingCollectionValue behandelt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:49:30 +02:00
marcoandClaude Opus 4.8 de32f6201c Update-Check: Proxy-Authentifizierung unterstützen
Hinter authentifizierten Unternehmens-Proxys (NTLM/Kerberos) scheiterte der
Update-Check mit HTTP 407, weil Invoke-RestMethod keine Anmeldeinformationen
an den Proxy sendet. Jetzt werden die angemeldeten Windows-Credentials an den
System-Proxy durchgereicht; optionaler expliziter Proxy via settings.json ->
update.proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 10:59:02 +02:00
marcoandClaude Opus 4.8 e110763e6a v0.1.34 — Verwaltungsart-Spalte und Autopilot-Zeitstempel
Build & Release MSI / build-msi (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 09:49:43 +02:00
marcoandClaude Opus 4.8 dfc1128f31 Geräte-Tab: Verwaltungsart-Spalte und Autopilot-Zeitstempel
- Neue Spalte "Verwaltung" (Intune vs. Co-Managed) in der Geräteliste,
  abgeleitet aus managementAgent; farbige Badges, sortierbar, im CSV-Export
  und im Detail-Panel.
- Detail-Panel zeigt Autopilot-Daten (Registriert, Profil zugewiesen am,
  letzter Kontakt) per Lookup über die Seriennummer. Ein echtes Importdatum
  liefert Graph nicht; verfügbar sind nur diese Zeitstempel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 07:49:14 +02:00
marcoandClaude Opus 4.8 e4efb0ac32 v0.1.33 — Offboarding-Report und Update-Hinweis
Build & Release MSI / build-msi (push) Canceled after 0s
- Offboarding: HTML-Report pro Geraet (New-OffboardHtmlReport) + reportUrl;
  Dialog-Ergebnis pro Geraet gruppiert + Report-Link
- Update-Check: GET /api/update/check (Gitea releases/latest, Compare-SemVer);
  Kopfzeilen-Badge bei neuer Version, Link zur Release-Seite; optionaler read-only Token

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:59:30 +02:00
marcoandClaude Opus 4.8 968f47b94f v0.1.32 — Bulk-Offboarding aus dem Geraete-Tab, Geraete-Ladefehler behoben
Build & Release MSI / build-msi (push) Canceled after 0s
- Bulk-Offboarding aus dem Geraete-Tab (Checkboxen + Alle, POST /api/offboard/resolve)
- Fix "Geraete konnten nicht geladen werden" (400): managementState raus dem $select,
  $orderby entfernt, Namensfilter-Fallback auf deviceName, beta-Endpoint
- Bessere Graph-Fehler-Diagnose (Graph-Body an die Meldung anhaengen)
- Offboard: Grund fuer nicht gefundene Autopilot-Zuordnung sichtbar (autopilotNote)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 22:08:46 +02:00
13 changed files with 1158 additions and 60 deletions
+64
View File
@@ -5,6 +5,70 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan
--- ---
## [0.1.36] - 2026-09-16
Bulk-Gruppenzuweisung für Policies und Autopilot im Offboarding.
### Neu
- **Gruppen zuweisen (Bulk)** im Policies-Tab: mehreren ausgewählten Policies (typübergreifend) in einem Schritt dieselben **Include-/Exclude-Gruppen** zuweisen — plus die integrierten Ziele **Alle Geräte** und **Alle Benutzer**. Zwei Modi: **Hinzufügen** (bestehende Zuweisungen bleiben erhalten und werden gemergt, da Graph `/assign` die komplette Liste ersetzt) und **Ersetzen**. Neuer Parameter `mode`/`allDevices`/`allUsers` an `POST /api/policies/assign`.
- **Offboarding durchsucht jetzt auch Autopilot**: Reine Autopilot-Geräte (registriert, aber nicht/nicht mehr in Intune enrolled) tauchten bisher nicht auf. Die Suche fragt zusätzlich `windowsAutopilotDeviceIdentities` ab (Seriennummer bzw. Anzeigename), dedupliziert gegen die Intune-Treffer und lädt für Autopilot-only-Geräte das Entra-Objekt per `azureAdDeviceId` nach.
### Doku
- README: vollständige Graph-Berechtigungen für den vollen Funktionsumfang, nach Bereich gruppiert (Kern, Geräte-Tab, Offboarding, Read-Only) — inkl. `DeviceManagementServiceConfig.*` (Autopilot-Status im Geräte-Detail, Autopilot-Suche im Offboarding). `docs/App-Registration.md` und `docs/Setup-AppRegistration.ps1` (`-IncludeDeviceActions`) entsprechend ergänzt.
---
## [0.1.35] - 2026-09-15
Settings-Catalog-Import-Fix und Update-Check hinter Proxy.
### Behoben
- **Settings-Catalog-Import** (400 „Property choiceSettingCollectionValue … does not match schema"): `choiceSettingCollectionValue` fehlte in der Array-Normalisierung. Beim JSON-Roundtrip unter PowerShell 5.1 wurde ein Ein-Element-`choiceSettingCollectionValue` zum Einzelobjekt entpackt und von Graph abgelehnt (z. B. „Default Defender Antivirus Policy"). Wird jetzt wie `group-`/`simpleSettingCollectionValue` behandelt.
- **Update-Check hinter authentifiziertem Proxy** (HTTP 407): Der Versions-Check schlug hinter Unternehmens-Proxys (NTLM/Kerberos) still fehl, weil `Invoke-RestMethod` keine Anmeldeinformationen an den Proxy sendete. Die angemeldeten Windows-Credentials werden jetzt an den System-Proxy durchgereicht; optionaler expliziter Proxy über `settings.json → update.proxy`.
---
## [0.1.34] - 2026-09-14
Verwaltungsart in der Geräteliste, Autopilot-Zeitstempel im Detail.
### Neu
- **Spalte „Verwaltung"** in der Geräteliste: zeigt je Gerät, ob es **Intune** (reines MDM) oder **Co-Managed** (ConfigMgr + Intune) ist — abgeleitet aus `managementAgent`. Farbige Badges, sortierbar, auch im CSV-Export und im Detail-Panel.
- **Autopilot-Infos im Geräte-Detail**: Beim Auswählen eines Windows-Geräts wird die Autopilot-Identity per Seriennummer nachgeladen und zeigt **Registrierungsstatus**, **Profil zugewiesen am** (`deploymentProfileAssignedDateTime`) und **letzter Autopilot-Kontakt** (`lastContactedDateTime`). Hinweis: Ein exaktes Autopilot-Importdatum liefert Microsoft Graph nicht; angezeigt werden die verfügbaren Zeitstempel. Kein Performance-Einfluss auf die Liste (Lookup nur pro ausgewähltem Gerät), keine neue Berechtigung nötig.
---
## [0.1.33] - 2026-09-13
Offboarding-Report und Update-Hinweis.
### Neu
- **Offboarding-Report**: Nach dem Ausführen erzeugt das Offboarding einen **HTML-Report** (`/reports/offboard-report-<zeit>.html`) im Stil des Zuweisungs-Reports — mit KPIs (Geräte / Aktionen / Erfolg / Fehler) und **pro Gerät** einer Tabelle der ausgeführten Löschungen (Dienst · Aktion · Status · Meldung). Das Ergebnis im Dialog ist jetzt ebenfalls **pro Gerät gruppiert**, plus ein Button „HTML-Report öffnen".
- **Update-Hinweis**: Beim Start prüft die App (nicht-blockierend) die neueste Release-Version über **Gitea** und zeigt bei einer neueren Version ein **Badge in der Kopfzeile** mit Link zur Release-Seite (dort MSI herunterladen → In-Place-Upgrade). Nur Lesezugriff; optionaler read-only Token via `settings.json → update.token`, falls das Repo privat ist. Neuer Endpoint `GET /api/update/check`.
---
## [0.1.32] - 2026-09-11
Bulk-Offboarding aus dem Geräte-Tab, Geräte-Ladefehler behoben, bessere Graph-Diagnose.
### Neu
- **Bulk-Offboarding aus dem Geräte-Tab**: Geräte per Checkbox (inkl. „Alle sichtbaren") auswählen und über den Button **„Offboarden"** direkt in den Offboard-Dialog übergeben — kombinierbar mit dem Gruppen-Filter (z. B. alle Geräte einer Gruppe). Die Auswahl wird server-seitig zu vollständigen Offboard-Objekten aufgelöst (Entra-Object-Id + Autopilot-Id nachgeladen). Neuer Endpoint `POST /api/offboard/resolve`. Im Read-Only-Modus ausgeblendet.
### Behoben
- **„Geräte konnten nicht geladen werden" (400 BadRequest)**: Intunes DeviceFE-Backend lehnte die Abfrage ab. Ursachen entfernt: `managementState` ist kein gültiges `$select`-Feld (raus), `$orderby` wird nicht mehr gesendet (Sortierung macht das Frontend), und der Namensfilter fällt bei nicht unterstütztem `startswith` automatisch auf `deviceName`-only zurück. Geräte-Endpoints nutzen jetzt `beta` (wie der Offboard-Abruf).
- **Bessere Graph-Fehler-Diagnose**: Bei Nicht-Retry-Fehlern wird der **Graph-Fehler-Body** an die Meldung gehängt (statt nur „BadRequest") — die eigentliche Ursache steht jetzt im Log/Toast.
- **Offboarding**: Wird eine Autopilot-Zuordnung nicht gefunden, zeigt der Dialog jetzt den **Grund** (fehlende Berechtigung vs. kein Seriennummer-Treffer), statt kommentarlos „Autopilot: –".
---
## [0.1.31] - 2026-09-11 ## [0.1.31] - 2026-09-11
Policy-Konsolidierung, Geräte-Export nach Gruppe, Gruppen-in-Gruppen, Geräte-Fixes. Policy-Konsolidierung, Geräte-Export nach Gruppe, Gruppen-in-Gruppen, Geräte-Fixes.
+79 -16
View File
@@ -105,24 +105,63 @@ Settings → Verbindung
Scopes RO: DeviceManagementApps.Read.All ... Scopes RO: DeviceManagementApps.Read.All ...
``` ```
### Benoetigt Graph-Berechtigungen ### Benoetigte Graph-Berechtigungen (voller Funktionsumfang)
| Funktion | Benoetigt | Alle Berechtigungen sind **delegiert** (Microsoft Graph) — die App handelt im Namen
|----------------------------------|----------------------------------------------| des angemeldeten Admins. Der **Kern** deckt App-Management, Group-Management und
| Apps anzeigen | `DeviceManagementApps.Read.All` | Policies ab; die **Geräte-** und **Offboarding**-Scopes sind optional und nur für
| Gruppen anzeigen / Mitglieder | `GroupMember.Read.All` | den jeweiligen Tab nötig. `offline_access` liefert das Refresh-Token (Device-Code-Flow).
| User suchen | `User.Read.All` |
| Zuweisungen aendern | `DeviceManagementApps.ReadWrite.All` | **Kern — immer erforderlich (App Management, Group Management, App Report, Policies)**
| Gruppen-Mitglieder aendern | `GroupMember.ReadWrite.All` |
| App-Installationszaehler (Report)| `DeviceManagementManagedDevices.Read.All` ⚠ | | Berechtigung | Wofür |
| Geraete-Exportliste pro App | `DeviceManagementManagedDevices.Read.All` ⚠ | |---|---|
| `Group.ReadWrite.All` | Gruppen anlegen, App-/Policy-Zuweisungen setzen |
| `GroupMember.ReadWrite.All` | Gruppenmitglieder lesen/hinzufügen/entfernen, CSV-Import |
| `User.Read.All` | Benutzersuche, UPN-Auflösung |
| `DeviceManagementApps.ReadWrite.All` | Apps auflisten, zuweisen, umbenennen, löschen, Setup-Datei aktualisieren |
| `DeviceManagementConfiguration.ReadWrite.All` | Policies (Settings Catalog, Compliance, Konfigurationsprofile, Administrative Vorlagen) lesen, exportieren, importieren, **zuweisen (Bulk)**, konsolidieren, Git-Snapshot |
| `DeviceManagementManagedDevices.Read.All` | App-Report (Installationszähler) und Geräte-Export pro App ⚠ |
| `offline_access` | Refresh-Token |
> ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App-Report nur > ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App-Report nur
> Zaehler = 0 und der Geraete-Export liefert keine Eintraege. > Zähler = 0 und der Geräte-Export liefert keine Einträge. Dieser Scope ist
> zugleich die Basis für den gesamten Geräte-Tab (siehe unten).
**Vollstaendige Berechtigungsliste, Redirect-URIs und ein Skript, das die **Geräte-Tab (optional)** — Geräte suchen, Detail-Panel, Aktionen
App-Registrierung automatisch anlegt/konfiguriert:**
[docs/App-Registration.md](docs/App-Registration.md) &mdash; inkl. | Berechtigung | Wofür |
|---|---|
| `DeviceManagementManagedDevices.Read.All` | Geräte auflisten, Detail-Panel, Spalte „Verwaltung" (Intune/Co-Managed) |
| `DeviceManagementServiceConfig.Read.All` | Autopilot-Status im Detail-Panel (Profil-Zuweisung / letzter Kontakt) |
| `DeviceManagementManagedDevices.ReadWrite.All` | Sync, Neustart, Remote-Lock, Diagnose, BitLocker-Key-Rotation |
| `DeviceManagementManagedDevices.PrivilegedOperations.All` | Wipe, Retire, Autopilot-Reset |
**Geräte-Offboarding (optional)** — Geräte aus Intune + Autopilot + Entra entfernen, Recovery-Keys lesen
| Berechtigung | Wofür |
|---|---|
| `DeviceManagementManagedDevices.Read.All` | Geräte-Suche (Intune) |
| `DeviceManagementServiceConfig.ReadWrite.All` | Autopilot-Suche **und** Löschen aus Autopilot |
| `DeviceManagementManagedDevices.ReadWrite.All` | Gerät aus Intune löschen |
| `Device.ReadWrite.All` | Gerät aus Entra ID löschen/deaktivieren |
| `BitlockerKey.Read.All` | BitLocker-Recovery-Keys lesen |
| `DeviceLocalCredential.Read.All` | LAPS-Passwörter lesen |
> **Rollen:** Das Löschen aus Entra/Intune/Autopilot benötigt bei delegierter
> Anmeldung zusätzlich passende **Verzeichnis-/Intune-Rollen** (z. B. *Cloud Device
> Administrator* bzw. *Intune Administrator*) — die Scopes allein reichen nicht
> (sonst `403`). Alle optionalen Scopes müssen außerdem in den **Read/Write-Scopes**
> des Tools stehen (Einstellungen → Verbindung), damit sie im Token landen.
**Read-Only-Variante** (reine Anzeige-App als `clientIdRo`): die `*.Read.All`-Pendants —
`Group.Read.All`, `GroupMember.Read.All`, `User.Read.All`,
`DeviceManagementApps.Read.All`, `DeviceManagementConfiguration.Read.All`,
`offline_access` (+ optional `DeviceManagementManagedDevices.Read.All` und
`DeviceManagementServiceConfig.Read.All` für den Geräte-Tab).
**Redirect-URIs und ein Skript, das die App-Registrierung automatisch
anlegt/konfiguriert:** [docs/App-Registration.md](docs/App-Registration.md) &mdash; inkl.
[`Setup-AppRegistration.ps1`](docs/Setup-AppRegistration.ps1). [`Setup-AppRegistration.ps1`](docs/Setup-AppRegistration.ps1).
--- ---
@@ -134,8 +173,8 @@ App-Registrierung automatisch anlegt/konfiguriert:**
| App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen | | App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen |
| Group Management | Mitglieder anzeigen/exportieren, Benutzer **und Gruppen** hinzufuegen, CSV-Import | | Group Management | Mitglieder anzeigen/exportieren, Benutzer **und Gruppen** hinzufuegen, CSV-Import |
| App Report | Installationszaehler pro App, Geraete-Export als CSV | | App Report | Installationszaehler pro App, Geraete-Export als CSV |
| Geräte | Geraete suchen, Filter (auch nach **Gruppe**), CSV-Export, Detail-Panel, Aktionen (Sync, Wipe, …) | | Geräte | Geraete suchen, Filter (auch nach **Gruppe**), Spalte **Verwaltung** (Intune / Co-Managed), CSV-Export, **Bulk-Offboarding**, Detail-Panel (inkl. **Autopilot**-Status) und Aktionen (Sync, Wipe, …) |
| Policies | Policies exportieren/importieren (Neuanlage) und **zusammenfuehren** (Settings Catalog) | | Policies | Policies exportieren/importieren (Neuanlage), **zusammenfuehren** (Settings Catalog) und **Gruppen zuweisen** (Bulk) |
--- ---
@@ -246,6 +285,29 @@ neu an. Unterstuetzte Typen:
Anlegen angezeigt; pro Konflikt waehlst du, welche Policy gewinnt Anlegen angezeigt; pro Konflikt waehlst du, welche Policy gewinnt
- Voraussetzung: gleiche Plattform. Zuweisungen werden nicht uebernommen. - Voraussetzung: gleiche Plattform. Zuweisungen werden nicht uebernommen.
### Gruppen zuweisen (Bulk) *(nur Read/Write)*
Weist mehreren ausgewaehlten Policies **in einem Schritt** dieselben Gruppen zu —
typuebergreifend (Settings Catalog, Compliance, Konfigurationsprofil,
Administrative Vorlage gemischt).
- Eine oder mehrere Policies per Checkbox waehlen → Button
**„Gruppen zuweisen"** in der Toolbar.
- Im Dialog **Include-** und/oder **Exclude-Gruppen** einmal waehlen; zusaetzlich
die integrierten Include-Ziele **Alle Geraete** und **Alle Benutzer**.
- **Zwei Modi:**
- **Hinzufuegen** (Default): bestehende Zuweisungen jeder Policy bleiben
erhalten, die gewaehlten Gruppen/Ziele kommen hinzu (dedupliziert, inkl.
vorhandener Filter- und Alle-Geraete/Benutzer-Ziele).
- **Ersetzen**: alle bisherigen Zuweisungen der Policy werden durch die
Auswahl ersetzt.
- Hintergrund: Die Graph-`/assign`-Action ersetzt immer die **komplette**
Zuweisungsliste — der Hinzufuegen-Modus liest die bestehenden Zuweisungen
daher zuerst aus und schickt sie zusammen mit den neuen mit. Ist das Auslesen
fuer eine Policy nicht moeglich, wird sie uebersprungen (statt versehentlich
bestehende Zuweisungen zu loeschen).
- Ergebnis-Toast meldet Erfolg/Fehler pro Policy.
### Benoetigt Graph-Berechtigung ### Benoetigt Graph-Berechtigung
`DeviceManagementConfiguration.ReadWrite.All` (Import) bzw. `DeviceManagementConfiguration.ReadWrite.All` (Import) bzw.
@@ -324,6 +386,7 @@ Intune Manager/
| GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten | | GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten |
| POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren | | POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren |
| POST | `/api/policies/consolidate` | **Write** | Settings-Catalog-Policies zusammenfuehren | | POST | `/api/policies/consolidate` | **Write** | Settings-Catalog-Policies zusammenfuehren |
| POST | `/api/policies/assign` | **Write** | Policies Gruppen/Alle-Geraete/-Benutzer zuweisen (Bulk, Add/Replace) |
> **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All` > **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All`
> **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import) > **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import)
+1 -1
View File
@@ -189,7 +189,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen Session = @() # geplante Zuweisungen
} }
$script:ToolVersion = "0.1.31" $script:ToolVersion = "0.1.36"
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host "" Write-Host ""
+3 -2
View File
@@ -38,7 +38,8 @@ Nur nötig, wenn der **Geräte-Tab** (Sync/Reboot/Lock/Diagnose/Wipe/Retire) gen
| Berechtigung | Wofür | | Berechtigung | Wofür |
|---|---| |---|---|
| `DeviceManagementManagedDevices.Read.All` | Geräte auflisten und Details anzeigen | | `DeviceManagementManagedDevices.Read.All` | Geräte auflisten und Details anzeigen, Spalte „Verwaltung" (Intune/Co-Managed) |
| `DeviceManagementServiceConfig.Read.All` | Autopilot-Status im Detail-Panel (Profil-Zuweisung / letzter Kontakt) |
| `DeviceManagementManagedDevices.ReadWrite.All` | Sync, Neustart, Remote-Lock, Diagnose, BitLocker-Key-Rotation | | `DeviceManagementManagedDevices.ReadWrite.All` | Sync, Neustart, Remote-Lock, Diagnose, BitLocker-Key-Rotation |
| `DeviceManagementManagedDevices.PrivilegedOperations.All` | Wipe, Retire, Autopilot-Reset | | `DeviceManagementManagedDevices.PrivilegedOperations.All` | Wipe, Retire, Autopilot-Reset |
@@ -51,7 +52,7 @@ entfernen, Recovery-Keys lesen) genutzt wird:
|---|---| |---|---|
| `Device.ReadWrite.All` | Gerät aus Entra ID löschen/deaktivieren | | `Device.ReadWrite.All` | Gerät aus Entra ID löschen/deaktivieren |
| `DeviceManagementManagedDevices.ReadWrite.All` | Gerät aus Intune löschen | | `DeviceManagementManagedDevices.ReadWrite.All` | Gerät aus Intune löschen |
| `DeviceManagementServiceConfig.ReadWrite.All` | Gerät aus Autopilot löschen | | `DeviceManagementServiceConfig.ReadWrite.All` | Autopilot durchsuchen und Gerät aus Autopilot löschen |
| `BitlockerKey.Read.All` | BitLocker-Recovery-Keys lesen | | `BitlockerKey.Read.All` | BitLocker-Recovery-Keys lesen |
| `DeviceLocalCredential.Read.All` | LAPS-Passwörter lesen | | `DeviceLocalCredential.Read.All` | LAPS-Passwörter lesen |
+14 -3
View File
@@ -33,7 +33,9 @@
.PARAMETER IncludeDeviceActions .PARAMETER IncludeDeviceActions
Nimmt zusaetzlich die Berechtigungen fuer den Geraete-Tab auf Nimmt zusaetzlich die Berechtigungen fuer den Geraete-Tab auf
(Read + ReadWrite + PrivilegedOperations = Sync/Reboot/Lock/Wipe/Retire). (Read + ReadWrite + PrivilegedOperations = Sync/Reboot/Lock/Wipe/Retire)
sowie DeviceManagementServiceConfig.Read.All fuer den Autopilot-Status
im Detail-Panel.
.PARAMETER GrantAdminConsent .PARAMETER GrantAdminConsent
Erteilt direkt tenantweiten Admin-Consent fuer die gesetzten Scopes. Erteilt direkt tenantweiten Admin-Consent fuer die gesetzten Scopes.
@@ -78,12 +80,16 @@ $perms = if ($ReadOnly) {
'DeviceManagementApps.ReadWrite.All','DeviceManagementConfiguration.ReadWrite.All','offline_access') 'DeviceManagementApps.ReadWrite.All','DeviceManagementConfiguration.ReadWrite.All','offline_access')
} }
if ($IncludeDeviceActions) { if ($IncludeDeviceActions) {
# DeviceManagementServiceConfig.Read.All: Autopilot-Status im Geraete-Detail
# (Profil-Zuweisung / letzter Kontakt) — ansonsten bleibt das Feld leer.
$perms += if ($ReadOnly) { $perms += if ($ReadOnly) {
@('DeviceManagementManagedDevices.Read.All') @('DeviceManagementManagedDevices.Read.All',
'DeviceManagementServiceConfig.Read.All')
} else { } else {
@('DeviceManagementManagedDevices.Read.All', @('DeviceManagementManagedDevices.Read.All',
'DeviceManagementManagedDevices.ReadWrite.All', 'DeviceManagementManagedDevices.ReadWrite.All',
'DeviceManagementManagedDevices.PrivilegedOperations.All') 'DeviceManagementManagedDevices.PrivilegedOperations.All',
'DeviceManagementServiceConfig.Read.All')
} }
} }
if ($IncludeOffboarding) { if ($IncludeOffboarding) {
@@ -99,6 +105,11 @@ if ($IncludeOffboarding) {
) )
} }
$perms = $perms | Select-Object -Unique $perms = $perms | Select-Object -Unique
# ServiceConfig: ReadWrite (Offboarding) schliesst Read (Autopilot-Anzeige) ein
# -> die Read-Variante nicht zusaetzlich anfordern.
if ($perms -contains 'DeviceManagementServiceConfig.ReadWrite.All') {
$perms = @($perms | Where-Object { $_ -ne 'DeviceManagementServiceConfig.Read.All' })
}
# --- Verbinden ---------------------------------------------------------------- # --- Verbinden ----------------------------------------------------------------
Write-Host "[1/6] Mit Microsoft Graph verbinden (Admin noetig)..." -ForegroundColor Cyan Write-Host "[1/6] Mit Microsoft Graph verbinden (Admin noetig)..." -ForegroundColor Cyan
+1 -1
View File
@@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") {
$env:DOTNET_ROOT = "C:\Program Files\dotnet" $env:DOTNET_ROOT = "C:\Program Files\dotnet"
} }
$version = "0.1.31" $version = "0.1.36"
$src = "\\Mac\Home\ClaudePRJ\Intune Manager" $src = "\\Mac\Home\ClaudePRJ\Intune Manager"
$stage = "$env:TEMP\IntuneManagerStage" $stage = "$env:TEMP\IntuneManagerStage"
$installerDir = "$src\installer" $installerDir = "$src\installer"
+352 -16
View File
@@ -21,6 +21,7 @@ function Invoke-ApiHandler {
"POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body } "POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body }
"POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body } "POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body }
"DELETE /api/settings/logo" { return Remove-LogoEndpoint } "DELETE /api/settings/logo" { return Remove-LogoEndpoint }
"GET /api/update/check" { return Get-UpdateCheckEndpoint }
"POST /api/connect" { return Invoke-ConnectEndpoint } "POST /api/connect" { return Invoke-ConnectEndpoint }
"POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body } "POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body }
"POST /api/connect/start" { return Start-DeviceCodeConnect } "POST /api/connect/start" { return Start-DeviceCodeConnect }
@@ -63,6 +64,7 @@ function Invoke-ApiHandler {
"POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body } "POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body }
"GET /api/offboard/search" { return Search-OffboardDevicesEndpoint -Query $Query } "GET /api/offboard/search" { return Search-OffboardDevicesEndpoint -Query $Query }
"POST /api/offboard/resolve" { return Get-OffboardResolveEndpoint -Body $Body }
"POST /api/offboard/keys" { return Get-OffboardKeysEndpoint -Body $Body } "POST /api/offboard/keys" { return Get-OffboardKeysEndpoint -Body $Body }
"POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body } "POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body }
} }
@@ -135,6 +137,83 @@ function Invoke-ApiHandler {
return $null return $null
} }
# ============================================================
# Update-Check (neueste Release-Version von Gitea vergleichen)
# ============================================================
# Semver-Vergleich: 1 wenn A>B, -1 wenn A<B, 0 gleich. Fuehrendes 'v' und
# Pre-Release/Build-Suffixe werden ignoriert; fehlende Teile zaehlen als 0.
function Compare-SemVer {
param([string]$A, [string]$B)
$clean = {
param($s)
$s = ([string]$s).Trim().TrimStart('v','V')
$s = ($s -split '[-+ ]')[0]
@($s -split '\.' | ForEach-Object { [int]([regex]::Match($_, '\d+').Value -as [int]) })
}
$pa = & $clean $A
$pb = & $clean $B
$len = [Math]::Max($pa.Count, $pb.Count)
for ($i = 0; $i -lt $len; $i++) {
$x = if ($i -lt $pa.Count) { [int]$pa[$i] } else { 0 }
$y = if ($i -lt $pb.Count) { [int]$pb[$i] } else { 0 }
if ($x -gt $y) { return 1 }
if ($x -lt $y) { return -1 }
}
return 0
}
# Prueft die neueste Release-Version. Quelle standardmaessig das Gitea-Repo;
# ueber Settings.update.apiUrl / .token ueberschreibbar (falls Repo privat).
# Braucht KEINE Graph-Verbindung. Fehler werden still zurueckgegeben (kein Banner).
function Get-UpdateCheckEndpoint {
$current = [string]$script:ToolVersion
$out = @{ current = $current; latest = ''; updateAvailable = $false; releaseUrl = ''; error = '' }
$api = 'https://git.wende.it/api/v1/repos/marco/Intune-Manager/releases/latest'
$token = ''
try {
if ($script:Settings.update) {
if ($script:Settings.update.apiUrl) { $api = [string]$script:Settings.update.apiUrl }
if ($script:Settings.update.token) { $token = [string]$script:Settings.update.token }
}
} catch {}
$headers = @{ 'User-Agent' = 'IntuneManager' }
if ($token) { $headers['Authorization'] = "token $token" }
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
# Proxy: Hinter authentifizierten Unternehmens-Proxys (NTLM/Kerberos) scheitert
# Invoke-RestMethod sonst mit "407 Proxyauthentifizierung erforderlich", weil
# standardmaessig keine Anmeldeinformationen an den Proxy gesendet werden. Die
# angemeldeten Windows-Credentials an den (System-)Proxy durchreichen. Optional
# laesst sich ein expliziter Proxy ueber Settings.update.proxy setzen.
$proxyArgs = @{}
try {
$proxyUrl = ''
if ($script:Settings.update -and $script:Settings.update.proxy) { $proxyUrl = [string]$script:Settings.update.proxy }
if ($proxyUrl) {
$proxyArgs['Proxy'] = $proxyUrl
$proxyArgs['ProxyUseDefaultCredentials'] = $true
} elseif ([System.Net.WebRequest]::DefaultWebProxy) {
[System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials
}
} catch {}
try {
$rel = Invoke-RestMethod -Uri $api -Headers $headers -Method GET -TimeoutSec 8 @proxyArgs
$latest = ([string]$rel.tag_name).TrimStart('v','V')
$out.latest = $latest
$out.releaseUrl = [string]$rel.html_url
$out.updateAvailable = ((Compare-SemVer $latest $current) -gt 0)
} catch {
$out.error = $_.Exception.Message
Write-Host "[UPDATE] Versions-Check fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkGray
}
return $out
}
# ============================================================ # ============================================================
# Status & Connection # Status & Connection
# ============================================================ # ============================================================
@@ -1649,7 +1728,7 @@ function Get-GroupDevicesExportEndpoint {
return @{ ok = $true; groupId = $GroupId; groupName = $groupName; userCount = 0; items = @(); count = 0; resolvedMs = [int]$sw.ElapsedMilliseconds } return @{ ok = $true; groupId = $GroupId; groupName = $groupName; userCount = 0; items = @(); count = 0; resolvedMs = [int]$sw.ElapsedMilliseconds }
} }
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,serialNumber,model,manufacturer,lastSyncDateTime,enrolledDateTime' $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,serialNumber,model,manufacturer,lastSyncDateTime,enrolledDateTime,managementAgent'
$batchSize = 20 $batchSize = 20
$devSeen = @{} $devSeen = @{}
$items = [System.Collections.Generic.List[object]]::new() $items = [System.Collections.Generic.List[object]]::new()
@@ -1696,6 +1775,8 @@ function Get-GroupDevicesExportEndpoint {
OS = [string]$d.operatingSystem OS = [string]$d.operatingSystem
OSVersion = [string]$d.osVersion OSVersion = [string]$d.osVersion
ComplianceState = [string]$d.complianceState ComplianceState = [string]$d.complianceState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = [string]$d.serialNumber SerialNumber = [string]$d.serialNumber
Model = [string]$d.model Model = [string]$d.model
Manufacturer = [string]$d.manufacturer Manufacturer = [string]$d.manufacturer
@@ -1779,17 +1860,28 @@ function Get-UserMemberOfEndpoint {
# Devices # Devices
# ============================================================ # ============================================================
# Verwaltungsart aus dem Graph-Feld 'managementAgent' ableiten.
# 'configurationManagerClientMdm' / 'configurationManagerClientMdmEas' -> Co-Managed
# (ConfigMgr + Intune), alles andere -> reines Intune (MDM).
function Get-ManagementType {
param([string]$Agent)
if ($Agent -match 'configurationManager') { return 'Co-Managed' }
return 'Intune'
}
function Search-DevicesEndpoint { function Search-DevicesEndpoint {
param($Query) param($Query)
$err = Test-Connected $err = Test-Connected
if ($err) { return $err } if ($err) { return $err }
$q = [string]$Query.q $q = ([string]$Query.q) -replace "'", "''" # OData-Escape fuer Apostroph
$os = [string]$Query.os $os = [string]$Query.os
$compliance = [string]$Query.compliance $compliance = [string]$Query.compliance
$top = 50 $top = 50
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime' # HINWEIS: 'managementState' ist KEIN gueltiges $select-Feld auf managedDevices
# -> fuehrt zu 400 BadRequest. Bewusst weggelassen.
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,serialNumber,model,manufacturer,enrolledDateTime,managementAgent'
# Ohne Suchbegriff: alle Geräte (erste Seite) # Ohne Suchbegriff: alle Geräte (erste Seite)
# Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName. # Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName.
@@ -1798,22 +1890,36 @@ function Search-DevicesEndpoint {
$snItems = @() $snItems = @()
if ($q -and $q.Length -ge 2) { if ($q -and $q.Length -ge 2) {
$filters = @() $osComp = @()
$nameFilter = "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))" if ($os) { $osComp += "operatingSystem eq '$os'" }
$filters += $nameFilter if ($compliance) { $osComp += "complianceState eq '$compliance'" }
if ($os) { $filters += "operatingSystem eq '$os'" } # Voller Namensfilter vs. nur deviceName. Manche Intune-Backends (DeviceFE)
if ($compliance) { $filters += "complianceState eq '$compliance'" } # unterstuetzen startswith NUR auf deviceName -> bei "Unsupported parameter"
$filterStr = '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' # (400) auf deviceName-only zurueckfallen. Kein $orderby mit $filter.
$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" $nameVariants = @(
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))",
$nameItems = @($resp.value) "startswith(deviceName,'$q')"
)
for ($vi = 0; $vi -lt $nameVariants.Count; $vi++) {
$filters = @($nameVariants[$vi]) + $osComp
$filterStr = '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&'
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top"
try {
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET
$nameItems = @($resp.value)
break
} catch {
if ($vi -eq $nameVariants.Count - 1) { throw } # letzter Versuch -> durchreichen
Write-Host " [DEVICES] Namensfilter nicht unterstuetzt -> Fallback auf deviceName-only" -ForegroundColor DarkYellow
}
}
# Seriennummer: exakter Vergleich (Graph unterstuetzt kein startswith auf serialNumber) # Seriennummer: exakter Vergleich (Graph unterstuetzt kein startswith auf serialNumber)
$snFilters = @("serialNumber eq '$q'") $snFilters = @("serialNumber eq '$q'")
if ($os) { $snFilters += "operatingSystem eq '$os'" } if ($os) { $snFilters += "operatingSystem eq '$os'" }
if ($compliance) { $snFilters += "complianceState eq '$compliance'" } if ($compliance) { $snFilters += "complianceState eq '$compliance'" }
$snFilter = '$filter=' + [uri]::EscapeDataString(($snFilters -join ' and ')) + '&' $snFilter = '$filter=' + [uri]::EscapeDataString(($snFilters -join ' and ')) + '&'
$snUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10" $snUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10"
try { try {
$snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET $snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET
$snItems = @($snResp.value) $snItems = @($snResp.value)
@@ -1823,7 +1929,9 @@ function Search-DevicesEndpoint {
if ($os) { $filters += "operatingSystem eq '$os'" } if ($os) { $filters += "operatingSystem eq '$os'" }
if ($compliance) { $filters += "complianceState eq '$compliance'" } if ($compliance) { $filters += "complianceState eq '$compliance'" }
$filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' } $filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' }
$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" # Kein $orderby: das Intune-DeviceFE-Backend lehnt es (mit/ohne Filter) teils ab.
# Die Sortierung macht ohnehin das Frontend.
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top"
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET
$nameItems = @($resp.value) $nameItems = @($resp.value)
} }
@@ -1845,6 +1953,8 @@ function Search-DevicesEndpoint {
ComplianceState = $d.complianceState ComplianceState = $d.complianceState
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState ManagementState = $d.managementState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = $d.serialNumber SerialNumber = $d.serialNumber
Model = $d.model Model = $d.model
Manufacturer = $d.manufacturer Manufacturer = $d.manufacturer
@@ -1859,12 +1969,38 @@ function Get-DeviceEndpoint {
$err = Test-Connected $err = Test-Connected
if ($err) { return $err } 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' # 'managementState' entfernt: kein gueltiges $select-Feld auf managedDevices (400).
$d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,managementAgent,totalStorageSpaceInBytes,freeStorageSpaceInBytes'
$d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET
$totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null } $totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null }
$freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null } $freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null }
# Autopilot-Identity per Seriennummer nachladen (nur Windows, fehlertolerant).
# HINWEIS: Ein echtes "Import-/Registrierungsdatum" liefert Graph nicht; verfuegbar
# sind nur Profil-Zuweisung (deploymentProfileAssignedDateTime) und letzter Kontakt.
$inAutopilot = $false
$apProfileAssign = $null
$apLastContact = $null
$sn = [string]$d.serialNumber
if ($sn -and ([string]$d.operatingSystem).ToLower() -eq 'windows') {
try {
$snEsc = $sn -replace "'", "''"
$apFilt = [Uri]::EscapeDataString("contains(serialNumber,'$snEsc')")
$apSel = 'id,serialNumber,deploymentProfileAssignedDateTime,lastContactedDateTime,enrollmentState'
$apUri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$apFilt&`$select=$apSel&`$top=1"
$apResp = Invoke-MgGraphRequestRetry -Uri $apUri -Method GET
$autop = @($apResp.value)[0]
if ($autop) {
$inAutopilot = $true
$apProfileAssign = ConvertTo-IsoDate $autop.deploymentProfileAssignedDateTime
$apLastContact = ConvertTo-IsoDate $autop.lastContactedDateTime
}
} catch {
Write-Host " [DEVICE] Autopilot-Lookup (SN=$sn): $($_.Exception.Message)" -ForegroundColor DarkYellow
}
}
return @{ return @{
Id = $d.id Id = $d.id
DeviceName = $d.deviceName DeviceName = $d.deviceName
@@ -1875,6 +2011,8 @@ function Get-DeviceEndpoint {
ComplianceState = $d.complianceState ComplianceState = $d.complianceState
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState ManagementState = $d.managementState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = $d.serialNumber SerialNumber = $d.serialNumber
Model = $d.model Model = $d.model
Manufacturer = $d.manufacturer Manufacturer = $d.manufacturer
@@ -1887,6 +2025,9 @@ function Get-DeviceEndpoint {
OwnerType = $d.managedDeviceOwnerType OwnerType = $d.managedDeviceOwnerType
TotalStorageGB = $totalGB TotalStorageGB = $totalGB
FreeStorageGB = $freeGB FreeStorageGB = $freeGB
InAutopilot = $inAutopilot
AutopilotProfileAssigned = $apProfileAssign
AutopilotLastContacted = $apLastContact
} }
} }
@@ -3585,3 +3726,198 @@ footer code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;bac
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path return $path
} }
# HTML-Report fuers Geraete-Offboarding — pro Geraet die ausgefuehrten Loeschungen
# (Entra/Intune/Autopilot) mit Status + Meldung. Liegt in Api.ps1 (BOM) wegen Umlauten.
function New-OffboardHtmlReport {
param($Devices, $Results, [int]$Success, [int]$Errors, [int]$Total, [string]$EntraAction, [bool]$DoIntune, [bool]$DoAutopilot)
Add-Type -AssemblyName System.Web
function _e { param($s) if ($null -eq $s) { return "" } return [System.Web.HttpUtility]::HtmlEncode([string]$s) }
$ts = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
$tsHuman = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
$name = "offboard-report-$ts.html"
$path = Join-Path $script:Config.ReportDir $name
$user = $env:USERNAME
$machine = $env:COMPUTERNAME
$tenant = if ($script:State.TenantId) { $script:State.TenantId } else { "" }
$account = if ($script:State.Account) { $script:State.Account } else { "" }
$devCount = @($Devices).Count
$bannerCls = if ($Errors -gt 0) { "banner-err" } else { "banner-ok" }
$bannerTxt = if ($Errors -gt 0) { "$Errors von $Total Aktion(en) fehlgeschlagen - Details unten" } else { "Alle $Success Aktion(en) erfolgreich" }
# Dienst-Zusammenfassung (was angefordert wurde)
$svcParts = @()
if ($EntraAction -eq 'delete') { $svcParts += "Entra ID: geloescht" }
elseif ($EntraAction -eq 'disable') { $svcParts += "Entra ID: deaktiviert" }
if ($DoIntune) { $svcParts += "Intune: geloescht" }
if ($DoAutopilot) { $svcParts += "Autopilot: geloescht" }
$svcTxt = if ($svcParts.Count -gt 0) { ($svcParts -join " &middot; ") } else { "keine" }
# Ergebnisse pro Geraet gruppieren (Reihenfolge = Geraeteliste)
$byDev = [ordered]@{}
foreach ($d in @($Devices)) { $dn = [string]$d.deviceName; if ($dn -and -not $byDev.Contains($dn)) { $byDev[$dn] = @() } }
foreach ($r in @($Results)) {
$dn = [string]$r.deviceName
if (-not $byDev.Contains($dn)) { $byDev[$dn] = @() }
$byDev[$dn] += $r
}
$devBlocks = ""
foreach ($dn in @($byDev.Keys)) {
$rows = @($byDev[$dn])
$okC = @($rows | Where-Object { $_.success }).Count
$errC = @($rows | Where-Object { -not $_.success }).Count
$rowsHtml = ""
foreach ($r in $rows) {
$cls = if ($r.success) { "ok" } else { "err" }
$ico = if ($r.success) { "&#10003;" } else { "&#33;" }
$lbl = if ($r.success) { "Erfolg" } else { "Fehler" }
$msg = if ($r.error) { _e $r.error } else { "" }
$rowsHtml += "<tr class='row-$cls'>" +
"<td class='c-status'><span class='status-pill status-$cls'>$ico $lbl</span></td>" +
"<td class='c-svc'>$(_e $r.service)</td>" +
"<td class='c-act'>$(_e $r.action)</td>" +
"<td class='c-msg'>$msg</td></tr>"
}
if (-not $rowsHtml) { $rowsHtml = "<tr><td colspan='4' class='c-msg'>Keine ausgefuehrten Aktionen.</td></tr>" }
$stats = ""
if ($okC -gt 0) { $stats += "<span class='hd-pill hd-ok'>$okC OK</span>" }
if ($errC -gt 0) { $stats += "<span class='hd-pill hd-err'>$errC Fehler</span>" }
$devBlocks += "<details class='dev-block' open><summary class='dev-head'>" +
"<span class='dev-name'>$(_e $dn)</span><span class='dev-stats'>$stats</span></summary>" +
"<div class='dev-body'><table class='res-table'><thead><tr>" +
"<th class='c-status'>Status</th><th class='c-svc'>Dienst</th><th class='c-act'>Aktion</th><th class='c-msg'>Meldung</th>" +
"</tr></thead><tbody>$rowsHtml</tbody></table></div></details>"
}
if (-not $devBlocks) { $devBlocks = "<div class='empty'>Keine Ergebnisse.</div>" }
$html = @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune Offboarding-Report - $tsHuman</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root{
--black-100:#0A0E15;--black-80:#373F4E;--black-60:#667085;
--white-100:#FFFFFF;--white-90:#F0F1F5;--white-80:#E0E4EB;--white-70:#D1D6E0;
--blue-60:#3566B6;--blue-10:#E0ECFF;
--success-100:#008072;--success-60:#A0E3D8;--success-10:#D0FBF5;
--error-100:#B21919;--error-60:#EA9E9E;--error-10:#FBE0E0;
--warning-100:#8F7200;--warning-60:#F8E081;--warning-10:#FFF3C2;
--bg:#F7FAFD;--surface:#FFF;--surface-soft:#F4F7FB;--hairline:var(--white-70);--hairline-soft:var(--white-90);
--text-primary:var(--black-100);--text-secondary:var(--black-80);--text-muted:var(--black-60);
--r-sm:6px;--r-md:10px;--r-lg:14px;--shadow-soft:0 1px 2px rgba(23,64,130,.04),0 2px 6px rgba(23,64,130,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{font-family:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text-secondary);line-height:1.5;-webkit-font-smoothing:antialiased}
body{padding:32px 16px 80px}
.container{max-width:1100px;margin:0 auto}
.head{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:24px 28px;margin-bottom:20px;box-shadow:var(--shadow-soft)}
.head-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:9px;background:var(--blue-60);color:#fff;display:grid;place-items:center;font-size:20px;font-weight:700}
.title{font-size:22px;font-weight:700;letter-spacing:-.015em;color:var(--text-primary)}
.subtitle{font-size:13px;color:var(--text-muted);margin-top:2px}
.head-meta{display:flex;flex-wrap:wrap;gap:6px}
.meta-pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;background:var(--surface-soft);border:1px solid var(--hairline);padding:5px 10px;border-radius:999px;color:var(--text-secondary);font-weight:500}
.meta-pill .lbl{color:var(--text-muted);text-transform:uppercase;font-size:10px;letter-spacing:.04em;font-weight:600}
.meta-pill code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px}
.banner{margin-top:18px;padding:12px 16px;border-radius:var(--r-md);font-size:13.5px;font-weight:500;display:flex;align-items:center;gap:10px;border:1px solid transparent}
.banner-ok{background:var(--success-10);border-color:var(--success-60);color:var(--success-100)}
.banner-err{background:var(--error-10);border-color:var(--error-60);color:var(--error-100)}
.banner-icon{width:22px;height:22px;border-radius:50%;display:inline-grid;place-items:center;font-size:13px;font-weight:700}
.banner-ok .banner-icon{background:var(--success-60);color:var(--success-100)}
.banner-err .banner-icon{background:var(--error-60);color:var(--error-100)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px}
.kpi{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-md);padding:18px}
.kpi-num{font-size:30px;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums;color:var(--text-primary)}
.kpi-lbl{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600;margin-top:6px}
.kpi.ok .kpi-num{color:var(--success-100)}
.kpi.err .kpi-num{color:var(--error-100)}
.section{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:22px 24px;margin-bottom:18px;box-shadow:var(--shadow-soft)}
.section-title{font-size:15px;font-weight:600;color:var(--text-primary);margin-bottom:4px}
.section-sub{font-size:12px;color:var(--text-muted);margin-bottom:14px}
.dev-block{border:1px solid var(--hairline);border-radius:var(--r-md);overflow:hidden;margin-bottom:10px;background:var(--surface)}
.dev-head{cursor:pointer;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;list-style:none}
.dev-head::-webkit-details-marker{display:none}
.dev-name{font-size:14px;font-weight:600;color:var(--text-primary)}
.dev-stats{display:flex;gap:6px}
.hd-pill{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px}
.hd-ok{background:var(--success-10);color:var(--success-100)}
.hd-err{background:var(--error-10);color:var(--error-100)}
.dev-body{border-top:1px solid var(--hairline-soft)}
.res-table{width:100%;border-collapse:collapse;font-size:13px}
.res-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);font-weight:600;padding:8px 16px;background:var(--surface-soft);border-bottom:1px solid var(--hairline-soft)}
.res-table td{padding:9px 16px;border-bottom:1px solid var(--hairline-soft);vertical-align:top}
.res-table tr:last-child td{border-bottom:none}
.c-status{width:120px}.c-svc{width:120px;font-weight:600;color:var(--text-primary)}.c-act{width:130px}
.c-msg{color:var(--error-100)}
.status-pill{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.status-ok{background:var(--success-10);color:var(--success-100);border-color:var(--success-60)}
.status-err{background:var(--error-10);color:var(--error-100);border-color:var(--error-60)}
.empty{padding:18px;text-align:center;color:var(--text-muted);font-size:13px;background:var(--surface-soft);border-radius:var(--r-md);border:1px dashed var(--hairline)}
footer{text-align:center;color:var(--text-muted);font-size:11.5px;margin-top:24px}
footer code{font-family:ui-monospace,Menlo,Consolas,monospace;background:var(--surface);padding:1px 6px;border-radius:4px;border:1px solid var(--hairline)}
@media print{body{background:#fff;padding:0}.section,.head,.kpi,.dev-block{box-shadow:none;break-inside:avoid}}
</style>
</head>
<body>
<div class="container">
<header class="head">
<div class="head-top">
<div class="brand">
<div class="logo">I</div>
<div>
<div class="title">Intune Offboarding-Report</div>
<div class="subtitle">$tsHuman &middot; ausgefuehrt von $(_e $user)</div>
</div>
</div>
<div class="head-meta">
"@
if ($account) { $html += "<span class='meta-pill'><span class='lbl'>Account</span> $(_e $account)</span>" }
if ($tenant) { $html += "<span class='meta-pill'><span class='lbl'>Tenant</span> <code>$(_e $tenant)</code></span>" }
$html += "<span class='meta-pill'><span class='lbl'>Host</span> $(_e $machine)</span>"
$html += @"
</div>
</div>
<div class="banner $bannerCls">
<span class="banner-icon">$(if ($Errors -gt 0) { '!' } else { '&#10003;' })</span>
<span>$bannerTxt</span>
</div>
</header>
<div class="kpis">
<div class="kpi tot"><div class="kpi-num">$devCount</div><div class="kpi-lbl">Geraete</div></div>
<div class="kpi tot"><div class="kpi-num">$Total</div><div class="kpi-lbl">Aktionen</div></div>
<div class="kpi ok"><div class="kpi-num">$Success</div><div class="kpi-lbl">Erfolgreich</div></div>
<div class="kpi err"><div class="kpi-num">$Errors</div><div class="kpi-lbl">Fehler</div></div>
</div>
<div class="section">
<div class="section-title">Angeforderte Dienste</div>
<div class="section-sub">$svcTxt</div>
</div>
<div class="section">
<div class="section-title">Ergebnisse pro Geraet</div>
<div class="section-sub">Jeder Block zeigt die ausgefuehrten Loeschungen/Aenderungen mit Status und Meldung.</div>
$devBlocks
</div>
<footer>
Intune Manager &middot; Web Edition <code>v$(_e $script:ToolVersion)</code> &middot; Offboarding-Report
</footer>
</div>
</body>
</html>
"@
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path
}
+5
View File
@@ -71,6 +71,11 @@ function Invoke-MgGraphRequestRetry {
Start-Sleep -Seconds $wait Start-Sleep -Seconds $wait
continue continue
} }
# Nicht-Retry-Fehler: den Graph-Fehler-Body (mit dem eigentlichen Grund)
# an die Meldung haengen — sonst steht im Log nur "BadRequest".
$gbody = $null
try { $gbody = [string]$_.ErrorDetails.Message } catch {}
if ($gbody) { throw [System.Exception]::new("$msg | Graph: $gbody", $_.Exception) }
throw throw
} }
} }
+152 -6
View File
@@ -54,7 +54,101 @@ function Search-OffboardDevicesEndpoint {
} }
return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" } return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" }
} }
if ($intune.Count -eq 0) { return @{ items = @(); count = 0 } } $items = @()
if ($intune.Count -gt 0) { $items = @(Resolve-OffboardItems -IntuneDevices $intune) }
# Zusaetzlich Autopilot direkt durchsuchen: Geraete, die in Autopilot registriert,
# aber (noch) nicht in Intune enrolled sind (oder aus Intune entfernt wurden),
# tauchen in der managedDevices-Suche nicht auf.
$apItems = @(Search-AutopilotOnly -Query $q -Type $type -ExistingItems $items)
$items = @($items) + @($apItems)
return @{ items = @($items); count = @($items).Count }
}
# Durchsucht windowsAutopilotDeviceIdentities direkt und liefert Offboard-Items fuer
# Geraete, die noch nicht ueber die Intune-Suche abgedeckt sind. Namenssuche laeuft
# ueber displayName (nicht garantiert unterstuetzt -> Fehler werden still ignoriert),
# Seriennummer ueber contains(serialNumber).
function Search-AutopilotOnly {
param([string]$Query, [string]$Type, [object[]]$ExistingItems)
$qEsc = $Query -replace "'", "''"
$apFilter = if ($Type -eq 'serial') { "contains(serialNumber,'$qEsc')" } else { "startswith(displayName,'$qEsc')" }
$sel = 'id,serialNumber,displayName,azureAdDeviceId,managedDeviceId,userPrincipalName,model,manufacturer,groupTag,enrollmentState'
$uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$([Uri]::EscapeDataString($apFilter))&`$select=$sel&`$top=50"
$ap = @()
try {
$ap = @(Get-GraphPaged -Uri $uri)
} catch {
$m = Get-OffboardGraphErr $_
Write-Host " [OFFBOARD] Autopilot-Suche ($Type='$Query'): $m" -ForegroundColor DarkYellow
return @()
}
if ($ap.Count -eq 0) { return @() }
# Bereits durch die Intune-Suche abgedeckte Geraete rausfiltern (Seriennummer/Autopilot-Id).
$seenSer = @{}; $seenApId = @{}
foreach ($it in @($ExistingItems)) {
$s = ([string]$it.serialNumber).Trim().ToLower(); if ($s) { $seenSer[$s] = $true }
$aid = [string]$it.autopilotId; if ($aid) { $seenApId[$aid] = $true }
}
$new = @($ap | Where-Object {
$s = ([string]$_.serialNumber).Trim().ToLower()
$aid = [string]$_.id
-not ($seenApId[$aid] -or ($s -and $seenSer[$s]))
})
if ($new.Count -eq 0) { return @() }
# Entra-Objekt per azureAdDeviceId nachladen (fuer Delete).
$reqs = @(); $idx = 0
foreach ($a in $new) {
$aad = [string]$a.azureAdDeviceId
if ($aad -and $aad -ne '00000000-0000-0000-0000-000000000000') {
$f = [Uri]::EscapeDataString("deviceId eq '$aad'")
$reqs += @{ id = "e$idx"; method = 'GET'; url = "/devices?`$filter=$f&`$select=id,deviceId,displayName,accountEnabled&`$top=1" }
}
$idx++
}
$batch = if ($reqs.Count -gt 0) { Invoke-GraphBatch -Requests $reqs } else { @{} }
$items = @(); $idx = 0
foreach ($a in $new) {
$entra = $null
$er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] }
$mdid = [string]$a.managedDeviceId
$hasIntune = ($mdid -and $mdid -ne '00000000-0000-0000-0000-000000000000')
$dn = [string]$a.displayName
if (-not $dn) { $dn = if ($a.serialNumber) { "SN $([string]$a.serialNumber)" } else { '(Autopilot-Geraet)' } }
$items += [pscustomobject]@{
deviceName = $dn
serialNumber = [string]$a.serialNumber
operatingSystem = 'Windows'
osVersion = ''
primaryUser = [string]$a.userPrincipalName
lastSync = $null
ownership = ''
coManaged = $false
intuneDeviceId = if ($hasIntune) { $mdid } else { '' }
azureADDeviceId = [string]$a.azureAdDeviceId
entraObjectId = if ($entra) { [string]$entra.id } else { '' }
entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null }
autopilotId = [string]$a.id
autopilotNote = ''
inIntune = [bool]$hasIntune
inEntra = [bool]$entra
inAutopilot = $true
}
$idx++
}
return @($items)
}
# Reichert Intune-managedDevice-Objekte mit Entra-Objekt-Id + Autopilot-Id an
# (per $batch) und liefert die Offboard-Item-Struktur fuers Frontend.
function Resolve-OffboardItems {
param([object[]]$IntuneDevices)
$intune = @($IntuneDevices)
if ($intune.Count -eq 0) { return @() }
# Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen. # Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen.
$reqs = @() $reqs = @()
@@ -77,9 +171,23 @@ function Search-OffboardDevicesEndpoint {
$items = @() $items = @()
$idx = 0 $idx = 0
foreach ($d in $intune) { foreach ($d in $intune) {
$entra = $null; $autop = $null $ser = [string]$d.serialNumber
$entra = $null; $autop = $null; $apNote = ''
$er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] } $er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] }
$ar = $batch["a$idx"]; if ($ar -and [int]$ar.status -eq 200) { $autop = @($ar.body.value)[0] } $ar = $batch["a$idx"]
if ($ar) {
$ast = [int]$ar.status
if ($ast -eq 200) {
$autop = @($ar.body.value)[0]
if (-not $autop) { $apNote = 'Kein Autopilot-Treffer fuer Seriennummer' }
} else {
$acode = ''; try { $acode = [string]$ar.body.error.code } catch {}
$apNote = "Autopilot-Lookup fehlgeschlagen: HTTP $ast" + $(if ($acode) { " ($acode)" } else { '' })
Write-Host " [OFFBOARD] Autopilot-Lookup ($($d.deviceName), SN=$ser): HTTP $ast $acode" -ForegroundColor DarkYellow
}
} elseif (-not $ser) {
$apNote = 'Keine Seriennummer am Intune-Geraet'
}
$items += [pscustomobject]@{ $items += [pscustomobject]@{
deviceName = [string]$d.deviceName deviceName = [string]$d.deviceName
serialNumber = [string]$d.serialNumber serialNumber = [string]$d.serialNumber
@@ -94,13 +202,39 @@ function Search-OffboardDevicesEndpoint {
entraObjectId = if ($entra) { [string]$entra.id } else { '' } entraObjectId = if ($entra) { [string]$entra.id } else { '' }
entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null } entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null }
autopilotId = if ($autop) { [string]$autop.id } else { '' } autopilotId = if ($autop) { [string]$autop.id } else { '' }
autopilotNote = $apNote
inIntune = $true inIntune = $true
inEntra = [bool]$entra inEntra = [bool]$entra
inAutopilot = [bool]$autop inAutopilot = [bool]$autop
} }
$idx++ $idx++
} }
return @{ items = @($items); count = $items.Count } return @($items)
}
# Offboard-Items zu einer Liste von Intune-Device-Ids aufloesen (fuer Bulk-
# Offboarding aus dem Geraete-Tab). Body: { ids: [intuneDeviceId, ...] }
function Get-OffboardResolveEndpoint {
param($Body)
$err = Test-Connected
if ($err) { return $err }
$ids = @(Get-PolicyProp $Body 'ids') | Where-Object { $_ }
if (@($ids).Count -eq 0) { return @{ items = @(); count = 0 } }
$sel = 'id,deviceName,serialNumber,operatingSystem,osVersion,userPrincipalName,lastSyncDateTime,azureADDeviceId,managedDeviceOwnerType,managementAgent'
$reqs = @()
$i = 0
foreach ($id in $ids) {
$reqs += @{ id = "d$i"; method = 'GET'; url = "/deviceManagement/managedDevices/$([string]$id)?`$select=$sel" }
$i++
}
$batch = Invoke-GraphBatch -Requests $reqs
$devs = @()
foreach ($k in @($batch.Keys)) {
$r = $batch[$k]
if ($r -and [int]$r.status -eq 200 -and $r.body) { $devs += $r.body }
}
return @{ items = @(Resolve-OffboardItems -IntuneDevices $devs); count = @($devs).Count }
} }
# Recovery-Keys eines Geraets holen (vor dem Loeschen). Body: # Recovery-Keys eines Geraets holen (vor dem Loeschen). Body:
@@ -232,7 +366,19 @@ function Invoke-OffboardExecuteEndpoint {
success = $ok; error = $errMsg success = $ok; error = $errMsg
} }
} }
$okCount = @($results | Where-Object { $_.success }).Count $okCount = @($results | Where-Object { $_.success }).Count
$errCount = $results.Count - $okCount
Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray
return @{ ok = $true; successCount = $okCount; total = $results.Count; results = @($results) }
# HTML-Report erzeugen (pro Geraet die ausgefuehrten Loeschungen) und verlinken.
$reportUrl = ""
try {
$reportFile = New-OffboardHtmlReport -Devices $devices -Results $results -Success $okCount -Errors $errCount -Total $results.Count -EntraAction $entraAction -DoIntune $doIntune -DoAutopilot $doAutopilot
if ($reportFile) { $reportUrl = "/reports/" + (Split-Path $reportFile -Leaf) }
Write-Host "[OFFBOARD] HTML-Report: $reportFile" -ForegroundColor DarkGray
} catch {
Write-Host "[OFFBOARD] Report-Erstellung fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Red
}
return @{ ok = $true; successCount = $okCount; total = $results.Count; results = @($results); reportUrl = [string]$reportUrl }
} }
+58 -3
View File
@@ -230,7 +230,7 @@ function ConvertTo-ImportBody {
# Arrays. Idempotent: bereits korrekte Arrays bleiben unveraendert. # Arrays. Idempotent: bereits korrekte Arrays bleiben unveraendert.
function Repair-SettingsCatalogArrays { function Repair-SettingsCatalogArrays {
param($Node) param($Node)
$arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','values') $arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','choiceSettingCollectionValue','values')
# *TemplateReference-Keys tragen ein Objekt ODER null. Ein alter Export mit zu # *TemplateReference-Keys tragen ein Objekt ODER null. Ein alter Export mit zu
# geringer ConvertTo-Json-Tiefe hat solche (tief liegenden) Objekte zu ".ToString()" # geringer ConvertTo-Json-Tiefe hat solche (tief liegenden) Objekte zu ".ToString()"
# stringifiziert -> "System.Collections.Hashtable". Graph lehnt das ab # stringifiziert -> "System.Collections.Hashtable". Graph lehnt das ab
@@ -706,27 +706,82 @@ function Invoke-PolicyAssignEndpoint {
$name = [string](Get-PolicyProp $it 'policyName') $name = [string](Get-PolicyProp $it 'policyName')
$include = @(Get-PolicyProp $it 'include') | Where-Object { $_ } $include = @(Get-PolicyProp $it 'include') | Where-Object { $_ }
$exclude = @(Get-PolicyProp $it 'exclude') | Where-Object { $_ } $exclude = @(Get-PolicyProp $it 'exclude') | Where-Object { $_ }
# Integrierte (virtuelle) Include-Ziele: Alle Geraete / Alle Benutzer.
$allDevices = [bool](Get-PolicyProp $it 'allDevices')
$allUsers = [bool](Get-PolicyProp $it 'allUsers')
# Modus: 'replace' (Default, Full-Replace wie bisher) oder 'add' (bestehende
# Zuweisungen erhalten und die neuen Gruppen dazu mergen). Die Graph-/assign-
# Action ersetzt IMMER die komplette Liste -> fuer 'add' muessen die
# bestehenden Zuweisungen vorher gelesen und mitgeschickt werden.
$mode = ([string](Get-PolicyProp $it 'mode')).ToLower()
if ($mode -ne 'add') { $mode = 'replace' }
$cfg = Get-PolicyTypeConfigByExportType $exportType $cfg = Get-PolicyTypeConfigByExportType $exportType
if (-not $cfg) { $results += @{ id = $id; policyName = $name; success = $false; error = "Unbekannter exportType: $exportType" }; continue } if (-not $cfg) { $results += @{ id = $id; policyName = $name; success = $false; error = "Unbekannter exportType: $exportType" }; continue }
if (-not $id) { $results += @{ policyName = $name; success = $false; error = 'Policy-Id fehlt' }; continue } if (-not $id) { $results += @{ policyName = $name; success = $false; error = 'Policy-Id fehlt' }; continue }
if (@($include).Count -eq 0 -and @($exclude).Count -eq 0) { if (@($include).Count -eq 0 -and @($exclude).Count -eq 0 -and -not $allDevices -and -not $allUsers) {
$results += @{ id = $id; policyName = $name; success = $true; skipped = $true } $results += @{ id = $id; policyName = $name; success = $true; skipped = $true }
continue continue
} }
$assignments = @() $assignments = @()
$seen = @{} # Dedup-Key "odataType|groupId" -> $true
if ($mode -eq 'add') {
# Bestehende Zuweisungen lesen und 1:1 uebernehmen (inkl. evtl. Filter/
# allDevices/allLicensedUsers). Schlaegt das Lesen fehl, brechen wir fuer
# diese Policy ab, statt versehentlich bestehende Zuweisungen zu loeschen.
try {
$existing = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assignments" -Method GET
foreach ($a in @($existing.value)) {
$t = $a.target
if (-not $t) { continue }
$ot = [string]$t.'@odata.type'
$gid = [string]$t.groupId
$key = "$ot|$gid"
if ($seen[$key]) { continue }
$seen[$key] = $true
$tgt = @{ '@odata.type' = $ot }
if ($gid) { $tgt['groupId'] = $gid }
foreach ($fld in @('deviceAndAppManagementAssignmentFilterId','deviceAndAppManagementAssignmentFilterType')) {
$v = $t.$fld
if ($null -ne $v -and [string]$v -ne '') { $tgt[$fld] = $v }
}
$assignments += @{ target = $tgt }
}
} catch {
$em = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $em = $_.ErrorDetails.Message } } catch {}
$results += @{ id = $id; policyName = $name; success = $false; error = "Bestehende Zuweisungen nicht lesbar (Hinzufuegen-Modus): $em" }
continue
}
}
foreach ($g in $include) { foreach ($g in $include) {
$key = "#microsoft.graph.groupAssignmentTarget|$g"
if ($seen[$key]) { continue }
$seen[$key] = $true
$assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } } $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } }
} }
foreach ($g in $exclude) { foreach ($g in $exclude) {
$key = "#microsoft.graph.exclusionGroupAssignmentTarget|$g"
if ($seen[$key]) { continue }
$seen[$key] = $true
$assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = [string]$g } } $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = [string]$g } }
} }
if ($allDevices) {
$key = '#microsoft.graph.allDevicesAssignmentTarget|'
if (-not $seen[$key]) { $seen[$key] = $true; $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } } }
}
if ($allUsers) {
$key = '#microsoft.graph.allLicensedUsersAssignmentTarget|'
if (-not $seen[$key]) { $seen[$key] = $true; $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } } }
}
$json = @{ assignments = @($assignments) } | ConvertTo-Json -Depth 10 $json = @{ assignments = @($assignments) } | ConvertTo-Json -Depth 10
$uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assign" $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assign"
try { try {
Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json' | Out-Null Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json' | Out-Null
$results += @{ id = $id; policyName = $name; success = $true; includeCount = @($include).Count; excludeCount = @($exclude).Count } $results += @{ id = $id; policyName = $name; success = $true; mode = $mode; includeCount = @($include).Count; excludeCount = @($exclude).Count }
} catch { } catch {
$m = $_.Exception.Message $m = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {} try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {}
+296 -12
View File
@@ -4784,6 +4784,24 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => {
// Init // Init
// ============================================================= // =============================================================
// Prueft (nicht-blockierend) auf eine neuere Release-Version und zeigt bei Bedarf
// das Update-Badge in der Kopfzeile mit Link zur Release-Seite.
async function checkForUpdate() {
try {
const res = await api('/api/update/check');
if (res && res.updateAvailable && res.releaseUrl) {
const badge = document.getElementById('updateBadge');
const txt = document.getElementById('updateBadgeText');
if (badge && txt) {
txt.textContent = `Update ${res.latest}`;
badge.href = res.releaseUrl;
badge.title = `Neue Version ${res.latest} verfügbar (aktuell ${res.current}) — Release-Seite öffnen`;
badge.classList.remove('hidden');
}
}
} catch (e) { /* still: Update-Check ist optional */ }
}
(async function init() { (async function init() {
renderConnection(); renderConnection();
renderTargets(); renderTargets();
@@ -4807,6 +4825,7 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => {
} }
await refreshStatus(); await refreshStatus();
refreshTenantSwitcher(); refreshTenantSwitcher();
checkForUpdate(); // nicht-blockierend
if (State.connected) autoLoadAfterConnect(); if (State.connected) autoLoadAfterConnect();
// First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen. // First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen.
if (setupNeeded) { if (setupNeeded) {
@@ -5776,7 +5795,8 @@ const DevState = {
sortAsc: true, sortAsc: true,
loaded: false, loaded: false,
groupId: null, // aktiver Gruppen-Filter (null = alle Geraete) groupId: null, // aktiver Gruppen-Filter (null = alle Geraete)
groupName: '' groupName: '',
checked: new Set() // per Checkbox ausgewaehlte Geraete-Ids (fuer Bulk-Offboarding)
}; };
async function devLoadAll() { async function devLoadAll() {
@@ -5789,6 +5809,7 @@ function devShowLoading(on) {
} }
async function devFetch() { async function devFetch() {
DevState.checked.clear(); // neue Datenladung -> Auswahl zuruecksetzen
devShowLoading(true); devShowLoading(true);
try { try {
if (DevState.groupId) { if (DevState.groupId) {
@@ -5851,12 +5872,20 @@ const DEV_COL_MAP = {
deviceName: 'DeviceName', deviceName: 'DeviceName',
userDisplayName: 'UserDisplayName', userDisplayName: 'UserDisplayName',
operatingSystem: 'OS', operatingSystem: 'OS',
managementType: 'ManagementType',
complianceState: 'ComplianceState', complianceState: 'ComplianceState',
lastSyncDateTime: 'LastSync', lastSyncDateTime: 'LastSync',
enrolledDateTime: 'EnrolledDateTime', enrolledDateTime: 'EnrolledDateTime',
}; };
const DEV_DATE_COLS = new Set(['LastSync', 'EnrolledDateTime']); const DEV_DATE_COLS = new Set(['LastSync', 'EnrolledDateTime']);
// Verwaltungsart als Badge: Co-Managed (ConfigMgr + Intune) vs. reines Intune.
function devMgmtBadge(type) {
if (type === 'Co-Managed') return '<span class="dev-badge dev-badge-comgmt">Co-Managed</span>';
if (type === 'Intune') return '<span class="dev-badge dev-badge-intune">Intune</span>';
return '<span class="dev-badge dev-badge-unk">—</span>';
}
function devFmtDate(iso) { function devFmtDate(iso) {
if (!iso) return '—'; if (!iso) return '—';
const d = new Date(iso); const d = new Date(iso);
@@ -5914,23 +5943,54 @@ function devRender() {
const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok' const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok'
: d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk'; : d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk';
const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : ''; const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : '';
const chk = DevState.checked.has(d.Id) ? 'checked' : '';
html += `<tr data-id="${escapeHtml(d.Id)}"${sel}> html += `<tr data-id="${escapeHtml(d.Id)}"${sel}>
<td class="dev-col-check"><input type="checkbox" class="dev-row-check" data-id="${escapeHtml(d.Id)}" ${chk}></td>
<td>${escapeHtml(d.DeviceName || '—')}</td> <td>${escapeHtml(d.DeviceName || '—')}</td>
<td>${escapeHtml(d.UserDisplayName || '—')}</td> <td>${escapeHtml(d.UserDisplayName || '—')}</td>
<td>${escapeHtml(d.OS || '—')}</td> <td>${escapeHtml(d.OS || '—')}</td>
<td>${devMgmtBadge(d.ManagementType)}</td>
<td><span class="dev-badge ${cmpCls}">${escapeHtml(d.ComplianceState || '—')}</span></td> <td><span class="dev-badge ${cmpCls}">${escapeHtml(d.ComplianceState || '—')}</span></td>
<td>${sync}</td> <td>${sync}</td>
</tr>`; </tr>`;
} }
tbody.innerHTML = html; tbody.innerHTML = html;
tbody.querySelectorAll('.dev-row-check').forEach(cb => {
cb.addEventListener('click', e => e.stopPropagation());
cb.addEventListener('change', () => {
if (cb.checked) DevState.checked.add(cb.dataset.id);
else DevState.checked.delete(cb.dataset.id);
devUpdateCheckSel();
});
});
tbody.querySelectorAll('tr').forEach(tr => { tbody.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => { tr.addEventListener('click', e => {
if (e.target.classList.contains('dev-row-check')) return;
const id = tr.dataset.id; const id = tr.dataset.id;
const device = DevState.filtered.find(d => d.Id === id); const device = DevState.filtered.find(d => d.Id === id);
if (device) devSelectDevice(device); if (device) devSelectDevice(device);
}); });
}); });
devUpdateCheckSel();
}
// Checkbox-Auswahl -> Offboard-Button + Zähler + Header-Checkbox synchronisieren.
function devUpdateCheckSel() {
// Nur sichtbare (gefilterte) Geraete zaehlen.
const visibleIds = DevState.filtered.map(d => d.Id);
const n = visibleIds.filter(id => DevState.checked.has(id)).length;
const btn = document.getElementById('btnDevOffboard');
if (btn) {
btn.disabled = n === 0;
const c = document.getElementById('devOffboardCount');
if (c) c.textContent = n;
}
const all = document.getElementById('devCheckAll');
if (all) {
all.checked = n > 0 && n === visibleIds.length;
all.indeterminate = n > 0 && n < visibleIds.length;
}
} }
async function devSelectDevice(device) { async function devSelectDevice(device) {
@@ -5967,12 +6027,16 @@ function devRenderDetail(d) {
['Benutzer', d.UserDisplayName], ['Benutzer', d.UserDisplayName],
['UPN', d.UserPrincipalName], ['UPN', d.UserPrincipalName],
['OS', d.OS ? `${d.OS} ${d.OSVersion || ''}`.trim() : null], ['OS', d.OS ? `${d.OS} ${d.OSVersion || ''}`.trim() : null],
['Verwaltung', d.ManagementType],
['Compliance', d.ComplianceState], ['Compliance', d.ComplianceState],
['Seriennummer', d.SerialNumber], ['Seriennummer', d.SerialNumber],
['IMEI', d.Imei], ['IMEI', d.Imei],
['Wi-Fi MAC', d.WiFiMac], ['Wi-Fi MAC', d.WiFiMac],
['Speicher', storage], ['Speicher', storage],
['Eingeschrieben', enrolled], ['Eingeschrieben', enrolled],
['Autopilot', d.InAutopilot ? 'Registriert' : (d.InAutopilot === false ? 'Nicht in Autopilot' : null)],
['Autopilot-Profil zugewiesen', d.AutopilotProfileAssigned ? devFmtDate(d.AutopilotProfileAssigned) : null],
['Autopilot letzter Kontakt', d.AutopilotLastContacted ? devFmtDate(d.AutopilotLastContacted) : null],
['Letzter Sync', sync], ['Letzter Sync', sync],
['Join-Typ', d.JoinType], ['Join-Typ', d.JoinType],
['Besitzertyp', d.OwnerType], ['Besitzertyp', d.OwnerType],
@@ -6171,12 +6235,12 @@ document.addEventListener('click', e => {
function devExportCsv() { function devExportCsv() {
const items = DevState.filtered || []; const items = DevState.filtered || [];
if (items.length === 0) { toast('Keine Geräte zum Exportieren.', 'warn'); return; } if (items.length === 0) { toast('Keine Geräte zum Exportieren.', 'warn'); return; }
const header = ['DeviceName','UserDisplayName','UserPrincipalName','OS','OSVersion','ComplianceState','SerialNumber','Model','Manufacturer','LastSync','EnrolledDateTime']; const header = ['DeviceName','UserDisplayName','UserPrincipalName','OS','OSVersion','ManagementType','ComplianceState','SerialNumber','Model','Manufacturer','LastSync','EnrolledDateTime'];
const rows = [header]; const rows = [header];
for (const d of items) { for (const d of items) {
rows.push([ rows.push([
d.DeviceName || '', d.UserDisplayName || '', d.UserPrincipalName || '', d.DeviceName || '', d.UserDisplayName || '', d.UserPrincipalName || '',
d.OS || '', d.OSVersion || '', d.ComplianceState || '', d.OS || '', d.OSVersion || '', d.ManagementType || '', d.ComplianceState || '',
d.SerialNumber || '', d.Model || '', d.Manufacturer || '', d.SerialNumber || '', d.Model || '', d.Manufacturer || '',
d.LastSync ? devFmtDate(d.LastSync) : '', d.EnrolledDateTime ? devFmtDate(d.EnrolledDateTime) : '', d.LastSync ? devFmtDate(d.LastSync) : '', d.EnrolledDateTime ? devFmtDate(d.EnrolledDateTime) : '',
]); ]);
@@ -6193,6 +6257,34 @@ function devExportCsv() {
document.getElementById('btnDevExport')?.addEventListener('click', devExportCsv); document.getElementById('btnDevExport')?.addEventListener('click', devExportCsv);
// Alle sichtbaren Geraete an-/abwaehlen
document.getElementById('devCheckAll')?.addEventListener('change', e => {
const on = e.target.checked;
DevState.filtered.forEach(d => { if (on) DevState.checked.add(d.Id); else DevState.checked.delete(d.Id); });
// Checkboxen in der Tabelle spiegeln, ohne komplett neu zu rendern
document.querySelectorAll('#devTableBody .dev-row-check').forEach(cb => { cb.checked = on; });
devUpdateCheckSel();
});
// Ausgewaehlte Geraete offboarden -> IDs aufloesen (Entra/Autopilot) und Dialog oeffnen
async function devOffboardSelected() {
const ids = DevState.filtered.filter(d => DevState.checked.has(d.Id)).map(d => d.Id);
if (ids.length === 0) { toast('Keine Geräte ausgewählt.', 'warn'); return; }
setLoading(`Löse ${ids.length} Gerät(e) für Offboarding auf…`);
try {
const res = await api('/api/offboard/resolve', { method: 'POST', body: { ids }, timeoutMs: 120000 });
const items = res.items || [];
if (items.length === 0) { toast('Geräte konnten nicht aufgelöst werden.', 'err'); return; }
// Offboard-Dialog mit genau diesen Geräten öffnen (unabhängig vom Offboard-Tab).
openOffboardModal(items);
} catch (e) {
toast('Offboarding-Auflösung fehlgeschlagen: ' + e.message, 'err');
} finally {
clearLoading();
}
}
document.getElementById('btnDevOffboard')?.addEventListener('click', devOffboardSelected);
// Sortierung // Sortierung
document.querySelectorAll('#devTable th.sortable').forEach(th => { document.querySelectorAll('#devTable th.sortable').forEach(th => {
th.addEventListener('click', () => { th.addEventListener('click', () => {
@@ -6317,6 +6409,12 @@ function polUpdateSelCount() {
const n = keys.length; const n = keys.length;
document.getElementById('polSelCount').textContent = n; document.getElementById('polSelCount').textContent = n;
document.getElementById('btnPolExport').disabled = n === 0; document.getElementById('btnPolExport').disabled = n === 0;
const btnA = document.getElementById('btnPolAssignGroups');
if (btnA) {
btnA.disabled = n === 0;
const c = document.getElementById('polAssignSelCount');
if (c) c.textContent = n;
}
// Konsolidieren nur bei >=2 Policies UND alle vom Typ Settings Catalog. // Konsolidieren nur bei >=2 Policies UND alle vom Typ Settings Catalog.
const btnC = document.getElementById('btnPolConsolidate'); const btnC = document.getElementById('btnPolConsolidate');
if (btnC) { if (btnC) {
@@ -6751,6 +6849,168 @@ document.getElementById('polAssignApply')?.addEventListener('click', async () =>
} }
}); });
// =============================================================
// Bulk: mehreren ausgewählten Policies dieselben Gruppen zuweisen
// =============================================================
const POL_TYPE_TO_EXPORT = {
settingscatalog: 'SettingsCatalog',
compliance: 'CompliancePolicy',
configuration: 'ConfigurationProfile',
administrativetemplate: 'AdministrativeTemplate',
};
const PolBulkAssign = { policies: [], include: [], exclude: [] };
let _polBulkSearchTimer = null;
function openPolBulkAssign() {
const sel = PolState.items.filter(p => PolState.selected.has(polKey(p)));
if (!sel.length) return;
PolBulkAssign.policies = sel.map(p => ({
id: p.id,
name: p.name || '(ohne Name)',
exportType: POL_TYPE_TO_EXPORT[p.type] || '',
}));
PolBulkAssign.include = [];
PolBulkAssign.exclude = [];
document.getElementById('polBulkCount').textContent = sel.length;
document.getElementById('polBulkNames').innerHTML = PolBulkAssign.policies.map(p =>
`<span class="pol-bulk-name">${escapeHtml(p.name)} <span class="pol-type-badge">${escapeHtml(POL_TYPE_LABELS[p.exportType] || 'Policy')}</span></span>`
).join('');
const addRadio = document.querySelector('input[name="polBulkMode"][value="add"]');
if (addRadio) addRadio.checked = true;
const ad = document.getElementById('polBulkAllDevices'); if (ad) ad.checked = false;
const au = document.getElementById('polBulkAllUsers'); if (au) au.checked = false;
renderPolBulkChips('include');
renderPolBulkChips('exclude');
updatePolBulkApply();
openModal('modalPolBulkAssign');
}
function renderPolBulkChips(kind) {
const box = document.querySelector(`.pol-assign-chips[data-scope="bulk"][data-kind="${kind}"]`);
if (!box) return;
box.innerHTML = PolBulkAssign[kind].map((g, j) => `
<span class="pol-assign-chip ${kind === 'exclude' ? 'exclude' : ''}">
${escapeHtml(g.name)}
<button type="button" data-kind="${kind}" data-j="${j}" aria-label="Entfernen">×</button>
</span>`).join('');
updatePolBulkApply();
}
function updatePolBulkApply() {
const inc = PolBulkAssign.include.length, exc = PolBulkAssign.exclude.length;
const allDev = document.getElementById('polBulkAllDevices')?.checked || false;
const allUsr = document.getElementById('polBulkAllUsers')?.checked || false;
const has = inc + exc > 0 || allDev || allUsr;
const btn = document.getElementById('polBulkApply');
if (btn) btn.disabled = !has;
const info = document.getElementById('polBulkInfo');
if (info) {
if (!has) { info.textContent = 'Mindestens eine Gruppe oder ein integriertes Ziel wählen.'; }
else {
const parts = [];
if (allDev) parts.push('Alle Geräte');
if (allUsr) parts.push('Alle Benutzer');
if (inc) parts.push(`${inc} Include`);
if (exc) parts.push(`${exc} Exclude`);
info.textContent = parts.join(' · ');
}
}
}
function polBulkHideDropdowns() {
document.querySelectorAll('.pol-bulk-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; });
}
// Gruppensuche (debounced) im Bulk-Modal
document.getElementById('modalPolBulkAssign')?.addEventListener('input', e => {
const input = e.target.closest('.pol-bulk-search');
if (!input) return;
const kind = input.dataset.kind;
const dd = document.querySelector(`.pol-bulk-dropdown[data-kind="${kind}"]`);
const q = input.value.trim();
clearTimeout(_polBulkSearchTimer);
if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; }
if (dd) { dd.classList.remove('hidden'); dd.innerHTML = '<div class="msg">Suche…</div>'; }
_polBulkSearchTimer = setTimeout(async () => {
try {
const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`);
const items = (data.items || []).slice(0, 25);
if (!dd) return;
if (!items.length) { dd.innerHTML = '<div class="msg">Keine Gruppen gefunden.</div>'; return; }
dd.innerHTML = items.map(g => `
<div class="opt" data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<div>${escapeHtml(g.DisplayName)}</div>
${g.Description ? `<div class="desc">${escapeHtml(g.Description)}</div>` : ''}
</div>`).join('');
} catch (err) {
if (dd) dd.innerHTML = `<div class="msg">Suche fehlgeschlagen: ${escapeHtml(err.message)}</div>`;
}
}, 300);
});
// Option wählen / Chip entfernen im Bulk-Modal
document.getElementById('modalPolBulkAssign')?.addEventListener('click', e => {
const opt = e.target.closest('.pol-bulk-dropdown .opt');
if (opt) {
const dd = opt.closest('.pol-bulk-dropdown');
const kind = dd.dataset.kind;
const id = opt.dataset.id, name = opt.dataset.name;
const other = kind === 'include' ? 'exclude' : 'include';
if (!PolBulkAssign[kind].some(g => g.id === id) && !PolBulkAssign[other].some(g => g.id === id)) {
PolBulkAssign[kind].push({ id, name });
renderPolBulkChips(kind);
}
const input = document.querySelector(`.pol-bulk-search[data-kind="${kind}"]`);
if (input) input.value = '';
dd.classList.add('hidden'); dd.innerHTML = '';
return;
}
const rm = e.target.closest('.pol-assign-chip button');
if (rm) {
const kind = rm.dataset.kind, j = +rm.dataset.j;
PolBulkAssign[kind].splice(j, 1);
renderPolBulkChips(kind);
}
});
document.addEventListener('click', e => {
if (!e.target.closest('#modalPolBulkAssign .pol-assign-search-wrap')) polBulkHideDropdowns();
});
document.getElementById('polBulkAllDevices')?.addEventListener('change', updatePolBulkApply);
document.getElementById('polBulkAllUsers')?.addEventListener('change', updatePolBulkApply);
document.getElementById('polBulkApply')?.addEventListener('click', async () => {
const inc = PolBulkAssign.include.map(g => g.id);
const exc = PolBulkAssign.exclude.map(g => g.id);
const allDevices = document.getElementById('polBulkAllDevices')?.checked || false;
const allUsers = document.getElementById('polBulkAllUsers')?.checked || false;
if (!inc.length && !exc.length && !allDevices && !allUsers) return;
const mode = (document.querySelector('input[name="polBulkMode"]:checked')?.value) === 'replace' ? 'replace' : 'add';
const items = PolBulkAssign.policies.map(p => ({
exportType: p.exportType, id: p.id, policyName: p.name,
include: inc, exclude: exc, allDevices, allUsers, mode,
}));
setLoading('Weise Gruppen zu…');
try {
const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 180000 });
const results = res.results || [];
const fail = results.filter(r => !r.success);
if (fail.length) {
const first = fail[0];
toast(`${res.assignedCount || 0} zugewiesen, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Zuweisung');
} else {
toast(`Gruppen ${mode === 'add' ? 'hinzugefügt' : 'ersetzt'}: ${res.assignedCount || 0} Policy(s).`, 'ok', 'Zuweisung');
}
closeModal('modalPolBulkAssign');
} catch (e) {
toast('Zuweisung fehlgeschlagen: ' + e.message, 'err');
} finally {
clearLoading();
}
});
document.getElementById('btnPolAssignGroups')?.addEventListener('click', openPolBulkAssign);
// Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben + committen. // Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben + committen.
async function polGitSnapshot() { async function polGitSnapshot() {
setLoading('Erstelle Policy-Snapshot & committe…'); setLoading('Erstelle Policy-Snapshot & committe…');
@@ -6816,8 +7076,10 @@ document.querySelectorAll('#polTable th.sortable').forEach(th => {
// ============================================================= // =============================================================
// OFFBOARDING: Geräte aus Intune / Autopilot / Entra entfernen // OFFBOARDING: Geräte aus Intune / Autopilot / Entra entfernen
// ============================================================= // =============================================================
const OffState = { items: [], selected: new Set() }; const OffState = { items: [], selected: new Set(), pending: [] };
function offKey(d) { return d.intuneDeviceId; } // Eindeutiger Zeilen-Key: Intune-Geräte haben eine intuneDeviceId, reine
// Autopilot-Geräte nicht -> auf Autopilot-/Entra-Id bzw. Seriennummer ausweichen.
function offKey(d) { return d.intuneDeviceId || d.autopilotId || d.azureADDeviceId || d.entraObjectId || d.serialNumber || ''; }
function offSelectedDevices() { return OffState.items.filter(d => OffState.selected.has(offKey(d))); } function offSelectedDevices() { return OffState.items.filter(d => OffState.selected.has(offKey(d))); }
async function offSearch() { async function offSearch() {
@@ -6889,12 +7151,13 @@ function offUpdateSel() {
document.getElementById('offBtn').disabled = n === 0; document.getElementById('offBtn').disabled = n === 0;
} }
function openOffboardModal() { function openOffboardModal(devsArg) {
const devs = offSelectedDevices(); const devs = Array.isArray(devsArg) ? devsArg : offSelectedDevices();
if (!devs.length) return; if (!devs.length) return;
OffState.pending = devs;
document.getElementById('offConfirmList').innerHTML = devs.map(d => ` document.getElementById('offConfirmList').innerHTML = devs.map(d => `
<div class="off-confirm-row"><b>${escapeHtml(d.deviceName || '—')}</b> <div class="off-confirm-row"><b>${escapeHtml(d.deviceName || '—')}</b>
— Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}</div> — Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}${(!d.autopilotId && d.autopilotNote) ? ` <span class="off-note" style="color:var(--warning,#e0a030);">(${escapeHtml(d.autopilotNote)})</span>` : ''}</div>
`).join(''); `).join('');
document.getElementById('offModalInfo').textContent = `${devs.length} Gerät(e)`; document.getElementById('offModalInfo').textContent = `${devs.length} Gerät(e)`;
document.getElementById('offConfirmChk').checked = false; document.getElementById('offConfirmChk').checked = false;
@@ -6931,7 +7194,7 @@ async function offLoadKeys(devs) {
} }
async function offExecute() { async function offExecute() {
const devs = offSelectedDevices(); const devs = (OffState.pending && OffState.pending.length) ? OffState.pending : offSelectedDevices();
if (!devs.length) return; if (!devs.length) return;
const services = { const services = {
entra: document.querySelector('input[name="offEntra"]:checked').value, entra: document.querySelector('input[name="offEntra"]:checked').value,
@@ -6951,9 +7214,30 @@ async function offExecute() {
try { try {
const res = await api('/api/offboard/execute', { method: 'POST', body: { devices: payload, services }, timeoutMs: 180000 }); const res = await api('/api/offboard/execute', { method: 'POST', body: { devices: payload, services }, timeoutMs: 180000 });
const results = res.results || []; const results = res.results || [];
// Ergebnis pro Gerät gruppieren
const byDev = new Map();
for (const r of results) {
if (!byDev.has(r.deviceName)) byDev.set(r.deviceName, []);
byDev.get(r.deviceName).push(r);
}
const devHtml = [...byDev.entries()].map(([name, rows]) => {
const errs = rows.filter(r => !r.success).length;
const lines = rows.map(r =>
`<div class="off-result ${r.success ? 'ok' : 'fail'}">${r.success ? '✓' : '✗'} ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}</div>`
).join('');
return `<div class="off-result-dev">
<div class="off-result-devname">${escapeHtml(name || '—')}${errs ? ` <span class="off-result-badge">${errs} Fehler</span>` : ''}</div>
${lines}
</div>`;
}).join('');
const reportLink = res.reportUrl
? `<a href="${escapeHtml(res.reportUrl)}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm" style="margin-top:12px;display:inline-flex;align-items:center;gap:6px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
HTML-Report öffnen
</a>`
: '';
document.getElementById('offResults').innerHTML = document.getElementById('offResults').innerHTML =
`<div class="lbl">Ergebnis (${res.successCount}/${res.total})</div>` + `<div class="lbl">Ergebnis (${res.successCount}/${res.total})</div>${devHtml}${reportLink}`;
results.map(r => `<div class="off-result ${r.success ? 'ok' : 'fail'}">${r.success ? '✓' : '✗'} ${escapeHtml(r.deviceName)} — ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}</div>`).join('');
toast(`${res.successCount}/${res.total} Aktionen erfolgreich.`, res.successCount === res.total ? 'ok' : 'warn', 'Offboarding'); toast(`${res.successCount}/${res.total} Aktionen erfolgreich.`, res.successCount === res.total ? 'ok' : 'warn', 'Offboarding');
// Erfolgreich vollständig offgeboardete Geräte aus der Liste nehmen. // Erfolgreich vollständig offgeboardete Geräte aus der Liste nehmen.
const doneNames = new Set(results.filter(r => r.success).map(r => r.deviceName)); const doneNames = new Set(results.filter(r => r.success).map(r => r.deviceName));
+69
View File
@@ -41,6 +41,10 @@
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<a id="updateBadge" class="update-badge hidden" href="#" target="_blank" rel="noopener" title="Update verfügbar — Release-Seite öffnen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg>
<span id="updateBadgeText">Update</span>
</a>
<button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen"> <button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>
</button> </button>
@@ -507,6 +511,10 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
CSV CSV
</button> </button>
<button id="btnDevOffboard" class="btn btn-danger btn-sm dev-offboard-btn" disabled title="Ausgewählte Geräte offboarden (Intune / Autopilot / Entra)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Offboarden (<span id="devOffboardCount">0</span>)
</button>
</div> </div>
<div class="dev-layout"> <div class="dev-layout">
@@ -515,9 +523,11 @@
<table class="dev-table" id="devTable"> <table class="dev-table" id="devTable">
<thead> <thead>
<tr> <tr>
<th class="dev-col-check"><input type="checkbox" id="devCheckAll" title="Alle sichtbaren auswählen"></th>
<th class="sortable" data-col="deviceName">Gerät <span class="sort-ico"></span></th> <th class="sortable" data-col="deviceName">Gerät <span class="sort-ico"></span></th>
<th class="sortable" data-col="userDisplayName">Benutzer <span class="sort-ico"></span></th> <th class="sortable" data-col="userDisplayName">Benutzer <span class="sort-ico"></span></th>
<th class="sortable" data-col="operatingSystem">OS <span class="sort-ico"></span></th> <th class="sortable" data-col="operatingSystem">OS <span class="sort-ico"></span></th>
<th class="sortable" data-col="managementType">Verwaltung <span class="sort-ico"></span></th>
<th class="sortable" data-col="complianceState">Compliance <span class="sort-ico"></span></th> <th class="sortable" data-col="complianceState">Compliance <span class="sort-ico"></span></th>
<th class="sortable" data-col="lastSyncDateTime">Letzter Sync <span class="sort-ico"></span></th> <th class="sortable" data-col="lastSyncDateTime">Letzter Sync <span class="sort-ico"></span></th>
</tr> </tr>
@@ -609,6 +619,10 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/><circle cx="12" cy="3" r="1"/><circle cx="12" cy="21" r="1"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/><circle cx="12" cy="3" r="1"/><circle cx="12" cy="21" r="1"/></svg>
Git-Snapshot Git-Snapshot
</button> </button>
<button class="btn btn-secondary btn-sm pol-write" id="btnPolAssignGroups" disabled title="Ausgewählten Policies Gruppen zuweisen (Bulk)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Gruppen zuweisen (<span id="polAssignSelCount">0</span>)
</button>
<button class="btn btn-secondary btn-sm pol-write" id="btnPolConsolidate" disabled title="Ausgewählte Settings-Catalog-Policies zu einer neuen Policy zusammenführen"> <button class="btn btn-secondary btn-sm pol-write" id="btnPolConsolidate" disabled title="Ausgewählte Settings-Catalog-Policies zu einer neuen Policy zusammenführen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3v6a3 3 0 0 0 3 3h4a3 3 0 0 1 3 3v6"/><polyline points="3 7 7 3 11 7"/><polyline points="13 17 17 21 21 17"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3v6a3 3 0 0 0 3 3h4a3 3 0 0 1 3 3v6"/><polyline points="3 7 7 3 11 7"/><polyline points="13 17 17 21 21 17"/></svg>
Konsolidieren Konsolidieren
@@ -974,6 +988,58 @@
</div> </div>
</div> </div>
<!-- Bulk: mehreren ausgewählten Policies dieselben Gruppen zuweisen -->
<div id="modalPolBulkAssign" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Gruppen zuweisen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-hint" style="margin-bottom:12px;">
Die gewählten Include-/Exclude-Gruppen werden auf <b><span id="polBulkCount">0</span></b> ausgewählte Policy(s) angewendet.
</div>
<div id="polBulkNames" class="pol-bulk-names"></div>
<div class="pol-assign-pickers" style="margin-top:12px;">
<div class="pol-assign-picker" data-kind="include">
<label class="lbl">Include-Gruppen</label>
<div class="pol-assign-chips" data-scope="bulk" data-kind="include"></div>
<div class="pol-assign-search-wrap">
<input type="text" class="input pol-bulk-search" data-kind="include" placeholder="Gruppe suchen…" autocomplete="off">
<div class="pol-assign-dropdown pol-bulk-dropdown hidden" data-kind="include"></div>
</div>
</div>
<div class="pol-assign-picker" data-kind="exclude">
<label class="lbl">Exclude-Gruppen</label>
<div class="pol-assign-chips" data-scope="bulk" data-kind="exclude"></div>
<div class="pol-assign-search-wrap">
<input type="text" class="input pol-bulk-search" data-kind="exclude" placeholder="Gruppe suchen…" autocomplete="off">
<div class="pol-assign-dropdown pol-bulk-dropdown hidden" data-kind="exclude"></div>
</div>
</div>
</div>
<div class="pol-bulk-builtin" style="margin-top:14px;">
<label class="lbl">Integrierte Ziele (Include)</label>
<label class="pol-bulk-check"><input type="checkbox" id="polBulkAllDevices"> Alle Geräte</label>
<label class="pol-bulk-check"><input type="checkbox" id="polBulkAllUsers"> Alle Benutzer</label>
</div>
<div class="pol-bulk-mode" style="margin-top:16px;">
<label class="lbl">Modus</label>
<label class="pol-bulk-radio"><input type="radio" name="polBulkMode" value="add" checked>
<span><b>Hinzufügen</b> — bestehende Zuweisungen bleiben erhalten, neue Gruppen kommen dazu.</span></label>
<label class="pol-bulk-radio"><input type="radio" name="polBulkMode" value="replace">
<span><b>Ersetzen</b> — alle bisherigen Zuweisungen der Policy werden durch die gewählten Gruppen ersetzt.</span></label>
</div>
</div>
<div class="modal-foot">
<span id="polBulkInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="polBulkApply" disabled>Zuweisen</button>
</div>
</div>
</div>
<!-- MODAL: Geräte-Offboarding --> <!-- MODAL: Geräte-Offboarding -->
<div id="modalOffboard" class="modal hidden"> <div id="modalOffboard" class="modal hidden">
<div class="modal-backdrop" data-close></div> <div class="modal-backdrop" data-close></div>
@@ -1065,6 +1131,9 @@
.off-result { font-size:13px; padding:3px 0; overflow-wrap:anywhere; } .off-result { font-size:13px; padding:3px 0; overflow-wrap:anywhere; }
.off-result.ok { color:var(--success); } .off-result.ok { color:var(--success); }
.off-result.fail { color:var(--error); } .off-result.fail { color:var(--error); }
.off-result-dev { border:1px solid var(--hairline-soft, var(--hairline)); border-radius:8px; padding:8px 12px; margin-bottom:8px; }
.off-result-devname { font-weight:600; font-size:13px; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.off-result-badge { font-size:10.5px; font-weight:700; padding:1px 8px; border-radius:999px; background:rgba(245,80,80,0.18); color:var(--error); }
#offResults .lbl { display:block; font-size:12px; color:var(--body); margin:10px 0 4px; } #offResults .lbl { display:block; font-size:12px; color:var(--body); margin:10px 0 4px; }
</style> </style>
+64
View File
@@ -335,6 +335,24 @@ h1, h2, h3 {
.header-actions { display: flex; align-items: center; gap: 12px; } .header-actions { display: flex; align-items: center; gap: 12px; }
/* Update-Hinweis-Badge (neue Version verfuegbar) */
.update-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--brand-accent);
border: 1px solid var(--brand-accent);
font-size: 12px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition: filter var(--tx, 0.15s ease-out);
}
.update-badge:hover { filter: brightness(0.96); }
/* Theme-Toggle — Slide-Switch */ /* Theme-Toggle — Slide-Switch */
.theme-toggle { .theme-toggle {
appearance: none; appearance: none;
@@ -4969,6 +4987,7 @@ body.read-only .gex-add-pane,
body.read-only .gex-tab[data-tab="add"], body.read-only .gex-tab[data-tab="add"],
body.read-only .gex-tab[data-tab="import"], body.read-only .gex-tab[data-tab="import"],
body.read-only #gexPaneImport, body.read-only #gexPaneImport,
body.read-only .dev-offboard-btn,
body.read-only .pol-write { body.read-only .pol-write {
display: none !important; display: none !important;
} }
@@ -5373,6 +5392,8 @@ body.read-only .app-row {
} }
.dev-table thead th.sortable { cursor: pointer; } .dev-table thead th.sortable { cursor: pointer; }
.dev-table thead th.sortable:hover { color: var(--text); } .dev-table thead th.sortable:hover { color: var(--text); }
.dev-col-check { width: 34px; text-align: center; padding-left: 6px; padding-right: 6px; }
.dev-col-check input { cursor: pointer; }
.dev-table tbody tr { .dev-table tbody tr {
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--hairline-soft); border-bottom: 1px solid var(--hairline-soft);
@@ -5406,6 +5427,8 @@ body.read-only .app-row {
.dev-badge-ok { background: rgba(74,222,128,.15); color: #4ade80; } .dev-badge-ok { background: rgba(74,222,128,.15); color: #4ade80; }
.dev-badge-err { background: rgba(248,113,113,.15); color: #f87171; } .dev-badge-err { background: rgba(248,113,113,.15); color: #f87171; }
.dev-badge-unk { background: var(--hairline-soft); color: var(--muted); } .dev-badge-unk { background: var(--hairline-soft); color: var(--muted); }
.dev-badge-intune { background: rgba(96,165,250,.15); color: #60a5fa; }
.dev-badge-comgmt { background: rgba(251,191,36,.15); color: #fbbf24; }
/* Detail-Panel */ /* Detail-Panel */
.dev-detail { .dev-detail {
@@ -5529,6 +5552,47 @@ body.read-only .app-row {
font-size: 13px; font-size: 13px;
} }
/* Gruppen-Zuweisungs-Picker (Post-Import & Bulk) */
.pol-assign-pickers { display: flex; gap: 16px; flex-wrap: wrap; }
.pol-assign-picker { flex: 1 1 240px; min-width: 220px; }
.pol-assign-picker .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
.pol-assign-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.pol-assign-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 4px 2px 9px; border-radius: var(--r-pill, 999px);
font-size: 12px; background: rgba(96,165,250,.15); color: #60a5fa;
}
.pol-assign-chip.exclude { background: rgba(248,113,113,.15); color: #f87171; }
.pol-assign-chip button {
border: 0; background: transparent; color: inherit; cursor: pointer;
font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%;
}
.pol-assign-chip button:hover { background: rgba(0,0,0,.15); }
.pol-assign-search-wrap { position: relative; }
.pol-assign-dropdown {
position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 2px);
max-height: 240px; overflow-y: auto;
background: var(--canvas, #1b1b1b); border: 1px solid var(--hairline, #333);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
.pol-assign-dropdown .opt { padding: 7px 10px; cursor: pointer; font-size: 13px; }
.pol-assign-dropdown .opt:hover { background: var(--hover, rgba(255,255,255,.06)); }
.pol-assign-dropdown .opt .desc { font-size: 11px; color: var(--text-dim, #888); margin-top: 1px; }
.pol-assign-dropdown .msg { padding: 8px 10px; font-size: 12px; color: var(--text-dim, #888); }
/* Bulk-Zuweisung: Policy-Liste + Modus */
.pol-bulk-names { display: flex; flex-wrap: wrap; gap: 6px; max-height: 120px; overflow-y: auto; }
.pol-bulk-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px; border-radius: 6px; font-size: 12.5px;
background: var(--bg3, var(--hover)); color: var(--ink, inherit);
}
.pol-bulk-mode .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
.pol-bulk-radio { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 8px; font-size: 13px; cursor: pointer; }
.pol-bulk-radio input { margin-top: 2px; }
.pol-bulk-builtin .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
.pol-bulk-check { display: inline-flex; align-items: center; gap: 6px; margin-right: 18px; font-size: 13px; cursor: pointer; }
/* ============================================================= /* =============================================================
Multi-Tenant: Topbar-Umschalter + Profil-Editor Multi-Tenant: Topbar-Umschalter + Profil-Editor
============================================================= */ ============================================================= */