32 Commits
Author SHA1 Message Date
marcoandClaude Opus 4.8 40e26b9f32 Policies: ADMX-Presentation-Matching Hashtable-Bug behoben (PS7/Container)
Where-Object liefert bei einem Treffer ein Einzelobjekt. Ist das eine Hashtable
(PS7 ConvertFrom-Json -AsHashtable, wie im Docker-Container), war $sameType[0]
eine Schluessel-Suche nach Key 0 (=> $null) statt Array-Index -> Presentation
wurde als "nicht zuordenbar" gewertet und die Einstellung uebersprungen. Unter
Windows PowerShell 5.1 (PSCustomObjects) trat der Fehler nicht auf.
Fix: Where-Object-Ergebnis vor dem Indexieren in @() zwingen. Verifiziert per
Simulation gegen die realen Firefox-ADMX-Exportdaten (Default Search Engine,
URL for Home page).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 13:29:40 +02:00
marcoandClaude Opus 4.8 849cdd198e Policies: ADMX-Import-Diagnose bei Presentation-Mismatch verbessert
Die "nicht zuordenbar"-Meldung listet jetzt Quell- und Ziel-Presentations
(Typ + Label) auf, damit echte ADMX-Versionsunterschiede sofort sichtbar sind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 13:05:19 +02:00
marcoandClaude Opus 4.8 0e66e268f6 Policies: ADMX-Presentation-Matching robuster (Typ normalisieren + Fallbacks)
- @odata.type-Vergleich normalisiert (fuehrendes '#' egal) -> Text-Presentations
  wurden sonst wegen Formatunterschied nicht zugeordnet.
- Zusaetzlicher Fallback: hat die Ziel-Definition genau eine Presentation, wird
  sie genutzt (deckt Ein-Feld-Settings wie Textboxen ab).
- Definitions-Auflösung: classType zuerst (User/Device nicht verwechseln), dann
  categoryPath bevorzugen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 12:56:15 +02:00
marcoandClaude Opus 4.8 c1751bec4d Policies: ADMX-Import überspringt Einstellung sauber bei Presentation-Mismatch
Bei ingesteten ADMX mit abweichender Ziel-Version ließ sich eine Presentation
teils nicht zuordnen; bisher wurde die Einstellung dennoch (unvollständig)
gepostet -> 400 BadRequest. Jetzt wird die ganze Einstellung mit klarer Meldung
übersprungen, statt einen kaputten Body zu senden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 12:37:55 +02:00
marcoandClaude Opus 4.8 300c72eb4f Policies: Import ingesteter (custom) ADMX-Vorlagen tenantübergreifend
Custom-ADMX-Definitionen (policyType 'admxIngested') haben tenant-spezifische
IDs -> Binding per Export-Id scheiterte im Ziel-Tenant mit 404. Import löst
solche Definitionen jetzt im Ziel über displayName/classType/categoryPath neu
auf und bindet an die dortige Id; Presentations werden per label/@odata.type
(Fallback: Reihenfolge je Typ) zugeordnet. Eingebaute Vorlagen (admxBacked)
unverändert per stabiler Id. categoryPath wird dafür mitexportiert.

Voraussetzung: dieselbe ADMX ist im Ziel-Tenant importiert; fehlt sie, wird die
betroffene Einstellung mit klarer Meldung übersprungen (Rest wird angelegt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 09:21:53 +02:00
marcoandClaude Opus 4.8 ead650d326 Policies: Admin-Vorlagen-Export ohne verschachtelten $expand (MaxExpansionDepth=1)
Graph lehnt definitionValues?$expand=...,presentationValues($expand=presentation)
mit 400 ab ("$expand path too deep, max 1"). Jetzt definitionValues nur mit
'definition' expandieren (Tiefe 1) und presentationValues je definitionValue
separat mit 'presentation' nachladen (jeweils Tiefe 1). Liefert dieselben Daten
für den Re-Import (definition.id, presentation.id, value/values).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 09:05:34 +02:00
marcoandClaude Opus 4.8 b58aa1fde3 Policies: Admin-Vorlagen-Export gehärtet (resiliente Liste + sichtbare Fehler)
- Get-GraphPolicyList: $expand=assignments pro Typ absichern. groupPolicyConfigurations
  (Administrative Vorlagen) lehnt den Expand teils mit 400 ab -> dann scheiterte das
  Laden und der Export ließ die Policies still weg. Jetzt Fallback auf Laden ohne
  Zuweisungen, damit der Typ erscheint und exportierbar bleibt.
- Export-PoliciesEndpoint: Detail-Fehler nicht mehr verschlucken, sondern pro Policy
  in 'errors' zurückgeben (+ Log).
- Frontend: Export-Fehler werden im Toast gemeldet (kein stiller Teil-Export mehr).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 08:58:55 +02:00
marcoandClaude Opus 4.8 52dcfe6d84 Docker: docker-compose.yml bereinigt (verirrtes 'ls' + ungenutzter volumes-Block)
Gültiges YAML, generischer Default (Port 8080, Named Volume im-data), BRIDGE_PORT
entfernt (nginx lauscht fest auf 8080).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 14:38:56 +02:00
marcoandClaude Opus 4.8 f40be6e4bd Docker: interner nginx statt socat (Host-Header -> localhost)
Ursache des 404 bei Zugriff über IP/Proxy: .NET-HttpListener bedient den Prefix
http://localhost:8077/ nur bei Host: localhost. socat (L4) reicht den originalen
Host-Header durch -> 404. Jetzt sitzt ein winziger nginx im Container davor, der
den Host-Header auf localhost umschreibt (Upstream [::1]:8077, IPv4 als Fallback).
App-Code bleibt unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 14:35:24 +02:00
marcoandClaude Opus 4.8 c217f5f8f3 Docker: socat-Bridge auf richtige Loopback-Familie (IPv4/IPv6) ausrichten
.NET-HttpListener bindet den Prefix "localhost" im Container auf IPv6 ::1, socat
verband aber auf 127.0.0.1 -> "Connection refused". Entrypoint startet die App
jetzt zuerst, erkennt ob IPv4 oder IPv6 antwortet und richtet socat darauf aus.
Kein Eingriff in den App-Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 13:54:56 +02:00
marcoandClaude Opus 4.8 9adf4da0d1 Docker: Test-Setup für self-hosted Betrieb (ohne Änderung des App-Teils)
Neuer Ordner docker/ (Dockerfile, docker-entrypoint.sh, docker-compose.yml, README).
Der bestehende App-/Client-Teil (Start.ps1, src/, www/) bleibt unverändert und wird
nur ins Image kopiert. Container-Spezifika laufen ohne Codeeingriff:
- socat-Bridge 0.0.0.0:8080 -> 127.0.0.1:8077 (App bindet weiter nur loopback)
- Datenpfad via XDG_CONFIG_HOME=/data (Volume) - Get-AppDataDir nutzt das auf Linux
- Login über den vorhandenen Device-Code-Flow (WAM/Loopback geht im Container nicht)
Kein Caddy: der vorhandene Reverse-Proxy zieht Port 8080 an.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 13:28:43 +02:00
marcoandClaude Opus 4.8 fd9fe761bf CI: Mac-/Dev-Dateien nicht ins MSI packen
Staging schließt jetzt start-macos.command, tests/ und .gitattributes aus, damit
sie nicht im Windows-MSI landen. WiX-Harvest ist ohnehin dynamisch — der Build
funktioniert unverändert, das Paket wird nur sauberer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 16:18:01 +02:00
marcoandClaude Opus 4.8 2c20f25837 Start-Script für macOS (start-macos.command)
Bequemer Launcher für den Mac: prüft pwsh, installiert bei Bedarf einmalig
Microsoft.Graph.Authentication, gibt einen belegten Port frei (auf macOS kein
Auto-Kill) und startet Start.ps1; Argumente (-Port/-NoBrowser) werden
durchgereicht. .gitattributes erzwingt LF für *.sh/*.command, damit der Shebang
auf macOS nicht durch CRLF zerschossen wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 16:04:38 +02:00
marcoandClaude Opus 4.8 51c2f56dec Auth: http://localhost als Redirect-URI (Login auf macOS/Linux)
Auf macOS/Linux nutzt Connect-MgGraph den interaktiven Loopback-Browser-Flow
(http://localhost:<zufälliger Port>). Ohne registrierte Loopback-URI scheitert
der Login mit AADSTS50011. Setup-AppRegistration.ps1 legt jetzt zusätzlich
http://localhost an (portagnostisch); App-Registration.md dokumentiert es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 15:56:39 +02:00
marcoandClaude Opus 4.8 e652891c41 Cross-Platform: Spike für Betrieb unter PowerShell 7 auf macOS/Linux
Windows-Verhalten bleibt identisch (Pfade/Temp verifiziert). Portable Anpassungen:
- Get-AppDataDir/Test-IsWindows (Models.ps1): App-Daten unter macOS in
  ~/Library/Application Support, Linux in $XDG_CONFIG_HOME/~/.config;
  auf Windows unveraendert %APPDATA%. Settings- und Policy-Export-Pfad nutzen ihn.
- ReportDir ueber [IO.Path]::GetTempPath() statt $env:TEMP (auf macOS/Linux leer).
- Start.ps1: Browser plattformneutral oeffnen (open/xdg-open/Start-Process);
  WMI-Stale-Instanz-Erkennung nur noch auf Windows.
- Datei-/Ordner-Dialog: macOS-nativer osascript-Chooser statt System.Windows.Forms;
  auf nicht unterstuetzten Plattformen klare Fehlermeldung.

Auth (Device-Code-Flow) und HttpListener sind bereits plattformneutral. Test auf
echtem macOS steht aus; Packaging (.app/.dmg) folgt separat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 15:46:56 +02:00
marcoandClaude Opus 4.8 9fb42b4d7e Policies: Zuweisungen sichtbar, Ergebnis-Report, Post-Import-Parität (#1 #4 #5)
- #1 Spalte "Zuweisungen" in der Policy-Liste (Anzahl); Klick öffnet eine
  Detailansicht mit den konkreten Include-/Exclude-Gruppen und Alle Geräte/
  Benutzer. Get-GraphPolicyList lädt jetzt $expand=assignments und löst
  Gruppennamen in einem Bulk-Lookup (Resolve-GroupNamesBulk) auf.
- #4 Bulk-Zuweisung zeigt statt nur eines Toasts einen Ergebnis-Report pro
  Policy (zugewiesen / übersprungen / Fehler) und lädt die Liste neu.
- #5 Post-Import-Zuweisung erhält Alle Geräte/Alle Benutzer (Parität zur
  Bulk-Zuweisung) und nutzt dieselbe Ergebnisansicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 12:23:10 +02:00
marcoandClaude Opus 4.8 a7c8e88793 Doku: In-App-Hilfe um Geräte, Policies und Offboarding erweitert (#3)
help.md dokumentierte bisher nur App-Management. Neue Abschnitte für den
Geräte-Tab (inkl. Verwaltungsart, Autopilot-Status), Policies (inkl.
Bulk-Gruppenzuweisung mit Add/Replace) und Offboarding (inkl.
Autopilot-Suche); Tab-Übersicht ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 12:23:09 +02:00
marcoandClaude Opus 4.8 98aea3c303 Tests: Pester-Tests für PolicyIO-Payload-Transformationen (#2)
Deckt die Array-Normalisierung (Repair-SettingsCatalogArrays) inkl.
choiceSettingCollectionValue sowie Remove-PolicyNullProps ab — genau die
Bug-Klasse (PS-5.1-JSON-Roundtrip skalarisiert Ein-Element-Arrays), die uns
mehrfach getroffen hat. 9 Tests, klassische Pester-3/4-Syntax.
Ausführen: Invoke-Pester .\tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 12:23:09 +02:00
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
marcoandClaude Opus 4.8 bd73a9c3b2 v0.1.31 — Policy-Konsolidierung, Geraete-Export nach Gruppe, Gruppen-in-Gruppen
Build & Release MSI / build-msi (push) Canceled after 0s
- Policies zusammenfuehren (Settings Catalog -> eine neue Policy, Konfliktaufloesung)
- Geraete-Tab: Gruppen-Filter (Live-Suche) + CSV-Export der angezeigten Liste
- Group Management: bestehende Gruppen als Mitglied hinzufuegen (verschachtelt)
- Fix: Geraete "Invalid Date" (ISO-Normalisierung) + Datums-/Spalten-Sortierung
- Doku (README/CHANGELOG) + Version-Bump 0.1.31

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 09:01:02 +02:00
24 changed files with 2737 additions and 148 deletions
+4
View File
@@ -0,0 +1,4 @@
# Shell-Scripts muessen LF behalten - CRLF zerschiesst den Shebang auf macOS/Linux
# ("bad interpreter"). Gilt unabhaengig von core.autocrlf.
*.sh text eol=lf
*.command text eol=lf
+2 -2
View File
@@ -31,8 +31,8 @@ jobs:
run: |
$stage = "${{ runner.temp }}\stage"
New-Item -ItemType Directory -Force $stage | Out-Null
# Copy everything except .git, .github, installer
Get-ChildItem -Path . -Exclude '.git','.github','installer' |
# Copy everything except Repo-/CI-/Dev-/macOS-only Dateien.
Get-ChildItem -Path . -Exclude '.git','.github','installer','start-macos.command','tests','.gitattributes' |
Copy-Item -Destination $stage -Recurse -Force
- name: Install WiX Toolset v4
+81
View File
@@ -5,6 +5,87 @@ 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
Policy-Konsolidierung, Geräte-Export nach Gruppe, Gruppen-in-Gruppen, Geräte-Fixes.
### Neu
- **Policies zusammenführen** (Settings Catalog): Mehrere Settings-Catalog-Policies zu **einer neuen** Policy vereinen. Gleiche Einstellungen werden einmal übernommen, **Konflikte** (gleiche `settingDefinitionId`, anderer Wert) werden vor dem Anlegen angezeigt und pro Konflikt aufgelöst. Button „Konsolidieren" im Policies-Tab (ab 2 gewählten Settings-Catalog-Policies). Neuer Endpoint `POST /api/policies/consolidate`.
- **Geräte nach Gruppe exportieren**: Im **Geräte**-Tab neuer **Gruppen-Filter** (Live-Suche) — die Liste zeigt nur die Geräte der Gruppen-User (inkl. verschachtelter Gruppen) — plus **CSV-Export** der aktuell angezeigten Liste. Kombinierbar mit Suche/OS/Compliance. Neuer Endpoint `GET /api/groups/{id}/devices/export`.
- **Gruppen zu Gruppen hinzufügen**: Im Group-Management-Tab „Benutzer | Gruppen hinzufügen" ein Umschalter **Benutzer / Gruppen** — eine bestehende Gruppe als Mitglied (verschachtelte Gruppe) zur gewählten Gruppe hinzufügen.
### Behoben
- **Geräte „Invalid Date"**: `lastSyncDateTime`/`enrolledDateTime` werden jetzt server-seitig als ISO 8601 normalisiert (`ConvertTo-IsoDate`) — vorher kam unter PowerShell 5.1 ein nicht parsbares Format an. Anzeige zusätzlich robust (ungültig → „—").
- **Geräte-Sortierung**: Die Spalten-Sortierung war komplett wirkungslos (Header-Feldnamen ≠ Item-Properties). Jetzt sortieren alle Spalten korrekt, **Datums-Spalten numerisch** nach Zeitstempel (Klick auf „Letzter Sync" / „Eingeschrieben").
---
## [0.1.30] - 2026-09-03
Policy-Bulk-Import gehärtet, macOS-Apps, Gruppen-Suche & Direkt-Zuweisung an Apps.
+99 -23
View File
@@ -90,7 +90,7 @@ Schlaegt diese fehl, wird automatisch auf die **Read-Only-App-ID** (`clientIdRo`
- App-Zuweisung (Required / Available hinzufuegen und entfernen)
- Gruppen erstellen und loeschen
- Tab „Benutzer hinzufuegen" im Group Management
- Tab „Benutzer | Gruppen hinzufuegen" im Group Management
- Tab „CSV-Import" im Group Management
- Session-Ausfuehren-Button
- Alle Rename-/Delete-Buttons
@@ -105,24 +105,63 @@ Settings → Verbindung
Scopes RO: DeviceManagementApps.Read.All ...
```
### Benoetigt Graph-Berechtigungen
### Benoetigte Graph-Berechtigungen (voller Funktionsumfang)
| Funktion | Benoetigt |
|----------------------------------|----------------------------------------------|
| Apps anzeigen | `DeviceManagementApps.Read.All` |
| Gruppen anzeigen / Mitglieder | `GroupMember.Read.All` |
| User suchen | `User.Read.All` |
| Zuweisungen aendern | `DeviceManagementApps.ReadWrite.All` |
| Gruppen-Mitglieder aendern | `GroupMember.ReadWrite.All` |
| App-Installationszaehler (Report)| `DeviceManagementManagedDevices.Read.All` ⚠ |
| Geraete-Exportliste pro App | `DeviceManagementManagedDevices.Read.All` ⚠ |
Alle Berechtigungen sind **delegiert** (Microsoft Graph) — die App handelt im Namen
des angemeldeten Admins. Der **Kern** deckt App-Management, Group-Management und
Policies ab; die **Geräte-** und **Offboarding**-Scopes sind optional und nur für
den jeweiligen Tab nötig. `offline_access` liefert das Refresh-Token (Device-Code-Flow).
**Kern — immer erforderlich (App Management, Group Management, App Report, Policies)**
| Berechtigung | Wofür |
|---|---|
| `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
> 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
App-Registrierung automatisch anlegt/konfiguriert:**
[docs/App-Registration.md](docs/App-Registration.md) &mdash; inkl.
**Geräte-Tab (optional)** — Geräte suchen, Detail-Panel, Aktionen
| 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).
---
@@ -132,10 +171,10 @@ App-Registrierung automatisch anlegt/konfiguriert:**
| Tab | Beschreibung |
|--------------------|-------------------------------------------------------------|
| App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen |
| Group Management | Mitglieder anzeigen, exportieren, Benutzer hinzufuegen, CSV-Import |
| Group Management | Mitglieder anzeigen/exportieren, Benutzer **und Gruppen** hinzufuegen, CSV-Import |
| App Report | Installationszaehler pro App, Geraete-Export als CSV |
| Geräte | Geraete suchen, Detail-Panel, Aktionen (Sync, Wipe, …) |
| Policies | Policies exportieren (JSON-Download) und importieren (Neuanlage) |
| 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), **zusammenfuehren** (Settings Catalog) und **Gruppen zuweisen** (Bulk) |
---
@@ -148,10 +187,11 @@ App-Registrierung automatisch anlegt/konfiguriert:**
- Filter zum Einschraenken der Anzeige
- **Als CSV exportieren** laedt alle Mitglieder als `members-<Gruppe>-<Datum>.csv`
### Benutzer hinzufuegen *(nur Read/Write)*
### Benutzer | Gruppen hinzufuegen *(nur Read/Write)*
- Benutzer per Name, UPN oder E-Mail suchen
- Mehrere Benutzer auswaehlen und gemeinsam hinzufuegen
- Umschalter **Benutzer / Gruppen** oben im Tab
- Benutzer per Name, UPN oder E-Mail suchen — oder Gruppen per Name (verschachtelte Gruppen)
- Mehrere Eintraege auswaehlen und gemeinsam zur gewaehlten Gruppe hinzufuegen
### CSV-Import *(nur Read/Write)*
@@ -235,6 +275,39 @@ neu an. Unterstuetzte Typen:
> bekommen bei Bedarf einen Default-`scheduledActionsForRule`-Block, den Graph
> beim Anlegen zwingend verlangt.
### Konsolidieren *(nur Read/Write, nur Settings Catalog)*
- Mindestens zwei **Settings-Catalog**-Policies per Checkbox waehlen →
**„Konsolidieren"**
- Die Einstellungen werden zu **einer neuen** Policy vereint (Neuanlage) — gleiche
`settingDefinitionId` mit gleichem Wert wird einmal uebernommen
- **Konflikte** (gleiche Einstellung, unterschiedlicher Wert) werden vor dem
Anlegen angezeigt; pro Konflikt waehlst du, welche Policy gewinnt
- 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
`DeviceManagementConfiguration.ReadWrite.All` (Import) bzw.
@@ -295,8 +368,9 @@ Intune Manager/
| POST | `/api/groups/resolve-upns` | Read | UPN-Liste → UserId + DisplayName |
| GET | `/api/groups/{id}/members` | Read | Mitglieder einer Gruppe |
| GET | `/api/groups/{id}/members/export` | Read | Mitglieder als CSV |
| POST | `/api/groups/{id}/members` | **Write** | Benutzer zur Gruppe hinzufuegen |
| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer aus Gruppe entfernen |
| POST | `/api/groups/{id}/members` | **Write** | Benutzer/Gruppen zur Gruppe hinzufuegen |
| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer/Gruppe aus Gruppe entfernen |
| GET | `/api/groups/{id}/devices/export` | Read+MDM | Geraete der Gruppen-User als CSV |
| GET | `/api/users?q=` | Read | Benutzer suchen |
| GET | `/api/apps` | Read | Apps laden (cached) |
| POST | `/api/apps/refresh` | Read | Apps neu laden |
@@ -311,6 +385,8 @@ Intune Manager/
| POST | `/api/policies/export` | Read+Cfg | Ausgewaehlte Policies als JSON exportieren |
| GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten |
| POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren |
| 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+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import)
+20 -5
View File
@@ -158,7 +158,8 @@ $script:Config = @{
Scopes = @($script:Settings.connection.scopes)
ScopesRo = @($script:Settings.connection.scopesRo)
WebRoot = (Join-Path $root "www")
ReportDir = (Join-Path $env:TEMP "IntuneAppManager-Reports")
# Plattformneutrales Temp-Verzeichnis ($env:TEMP ist auf macOS/Linux leer).
ReportDir = (Join-Path ([System.IO.Path]::GetTempPath()) "IntuneAppManager-Reports")
}
if (-not (Test-Path $script:Config.ReportDir)) {
@@ -189,7 +190,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen
}
$script:ToolVersion = "0.1.30"
$script:ToolVersion = "0.1.36"
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host ""
@@ -206,7 +207,12 @@ Write-Host ""
# das alte Fenster bedient weiter mit altem Code. Darum alle anderen Start.ps1-
# Prozesse DIESES Pfads vorher beenden, damit ein Neustart wirklich neu laedt.
# ============================================================
try {
# Nur auf Windows: die WMI-basierte Instanz-Erkennung (Win32_Process) gibt es
# nur dort. Auf macOS/Linux uebernimmt das der Listener selbst (Port belegt ->
# Fehler beim Start), ein manueller Neustart genuegt.
$isWin = ($env:OS -eq 'Windows_NT') -or ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows
if ($isWin) {
try {
$thisScript = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($thisScript)) { $thisScript = Join-Path $PSScriptRoot 'Start.ps1' }
# Nur echte Server-Instanzen treffen: per -File gestartet UND auf diesen
@@ -220,8 +226,9 @@ try {
catch { Write-Host " konnte PID $($p.ProcessId) nicht beenden: $($_.Exception.Message)" -ForegroundColor DarkYellow }
}
if ($stale.Count -gt 0) { Start-Sleep -Milliseconds 800 } # kurz warten bis der Port frei ist
} catch {
} catch {
Write-Host " (Konnte alte Instanzen nicht pruefen: $($_.Exception.Message))" -ForegroundColor DarkGray
}
}
$url = "http://localhost:$Port/"
@@ -232,7 +239,15 @@ Write-Host " Beenden: Ctrl+C" -ForegroundColor DarkGray
Write-Host ""
if (-not $NoBrowser) {
Start-Process $url
# Browser plattformneutral oeffnen.
try {
if ($isWin) { Start-Process $url }
elseif ($IsMacOS) { & /usr/bin/open $url }
elseif ($IsLinux) { & xdg-open $url }
else { Start-Process $url }
} catch {
Write-Host " (Browser konnte nicht automatisch geoeffnet werden - bitte $url manuell aufrufen.)" -ForegroundColor DarkGray
}
}
Start-WebServer -Port $Port
+37
View File
@@ -0,0 +1,37 @@
# Intune Manager - Test-Image (self-hosted).
# WICHTIG: Der bestehende App-/Client-Teil wird NICHT veraendert - Start.ps1, src/
# und www/ werden nur unveraendert ins Image kopiert. Die einzigen Container-
# Spezifika (Bridge fuer den localhost-Listener, Datenpfad, Login) laufen ueber
# Env-Variablen bzw. das Entrypoint-Script - ohne Codeaenderung.
FROM mcr.microsoft.com/powershell:7.4-debian-12
# nginx = interner Reverse-Proxy, der den Host-Header auf "localhost" umschreibt
# (HttpListener-Prefix http://localhost:8077/ bedient nur Host: localhost).
# git = fuer das optionale Policy-Git-Snapshot-Feature.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx git ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& pwsh -NoProfile -Command "Install-Module Microsoft.Graph.Authentication -Scope AllUsers -Force"
WORKDIR /app
# Bestehende App unveraendert ins Image kopieren (Build-Kontext = Repo-Root).
COPY Start.ps1 ./
COPY src/ ./src/
COPY www/ ./www/
# Interner nginx-Reverse-Proxy (Host-Rewrite) + Entrypoint (neu, nur fuer Container).
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Datenverzeichnis: Get-AppDataDir nutzt auf Linux $XDG_CONFIG_HOME ->
# Settings/Policy-Exporte landen im Volume /data. Kein Codeeingriff noetig.
ENV XDG_CONFIG_HOME=/data \
APP_PORT=8077 \
BRIDGE_PORT=8080
VOLUME /data
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+76
View File
@@ -0,0 +1,76 @@
# Intune Manager - Docker-Test (self-hosted)
Test-Aufbau, um den Intune Manager als Container zu verproben. **Der bestehende
App-/Client-Teil (`Start.ps1`, `src/`, `www/`) wird nicht verändert** — er wird nur
unverändert ins Image kopiert. Container-Spezifika laufen über Env-Variablen und ein
Entrypoint-Script.
> Kein Caddy/TLS enthalten — der **vorhandene Reverse-Proxy** zieht den
> veröffentlichten Port (`8080`) an. HTTPS macht der Proxy.
## Wie es funktioniert
- Die App bindet unverändert auf `http://localhost:8077/` (nur containerintern) und
bedient — bedingt durch .NET-`HttpListener` — **nur Requests mit `Host: localhost`**.
- Ein **interner nginx** (Port 8080) leitet auf die App weiter und schreibt dabei den
**`Host`-Header auf `localhost`** um. Ohne dieses Rewrite käme bei Zugriff über IP/Proxy
ein `404`. Der App-Code bleibt unangetastet.
- Settings/Policy-Exporte liegen im Volume `/data` (`XDG_CONFIG_HOME=/data`, wird von
`Get-AppDataDir` auf Linux automatisch genutzt).
## Bauen & starten
```bash
docker compose -f docker/docker-compose.yml up --build -d
```
Logs ansehen:
```bash
docker compose -f docker/docker-compose.yml logs -f
```
Erwartet: „Intune Manager - Web Edition" und „URL: http://localhost:8077/".
Den Reverse-Proxy auf `http://<docker-host>:8080` zeigen lassen (reiner HTTP-Proxy,
keine WebSockets/Callbacks nötig).
## Erststart: Verbindung konfigurieren
Beim ersten Start ist noch kein Tenant hinterlegt. Zwei Wege:
- **UI:** Seite öffnen → *Einstellungen* → **Tenant-ID**, **Client-ID** (Read/Write),
optional **Client-ID (Read-Only)** und **Scopes** eintragen und speichern. Landet in
`/data/IntuneAppManager-Web/settings.json`.
- **oder** eine vorhandene `settings.json` ins Volume legen:
```bash
docker cp settings.json intune-manager-test:/data/IntuneAppManager-Web/settings.json
docker compose -f docker/docker-compose.yml restart
```
## Login (Device-Code)
Der interaktive „Verbinden"-Button nutzt WAM/Loopback und **funktioniert im Container
nicht** — das ist erwartet. Für den Test den bereits vorhandenen **Device-Code-Flow**
per `curl` anstoßen (Host = euer Reverse-Proxy oder `http://<docker-host>:8080`):
```bash
curl -s -X POST http://<host>/api/connect/start
```
Antwort enthält `code` und `urlComplete`. Dann:
1. `urlComplete` im Browser öffnen und als Admin anmelden.
2. Status pollen, bis verbunden:
```bash
curl -s http://<host>/api/status
```
(Feld `connected` wird `true`.)
3. Die UI im Browser **neu laden** → jetzt verbunden. Geräte/Gruppen/Policies laden
zum Verifizieren.
## Stoppen / Aufräumen
```bash
docker compose -f docker/docker-compose.yml down # Daten bleiben im Volume
docker compose -f docker/docker-compose.yml down -v # inkl. Daten löschen
```
## Wichtige Hinweise (für den echten Einsatz)
- **Kein eigener UI-Login und geteilte Sitzung:** Wer die URL erreicht, kann den Login
auslösen; die verbundene Graph-Sitzung gilt prozessweit. Die Instanz **zwingend hinter
Zugangsschutz** stellen (Reverse-Proxy-Auth / Netzsegment / VPN). Eine Instanz =
eine Team-Sitzung, kein sauberes Multi-Admin.
- Reports liegen unter `/tmp` (nicht persistent) — für den Test ausreichend.
- Das ist ein **Test-Setup**. Echtes Mehrbenutzer (Login pro Admin) wäre ein separater
Ausbau (Auth-Code-Flow + Sitzungs-Isolation).
+22
View File
@@ -0,0 +1,22 @@
# Intune Manager - Test-Compose (self-hosted).
# Kein Caddy/TLS: der bestehende Reverse-Proxy zieht den veroeffentlichten Port an.
services:
intune-manager:
build:
context: .. # Build-Kontext = Repo-Root (fuer COPY Start.ps1/src/www)
dockerfile: docker/Dockerfile
image: intune-manager-test
container_name: intune-manager-test
ports:
# linke Zahl = Host-Port. Frei waehlen, falls belegt (z. B. "8477:8080").
- "8080:8080"
volumes:
# Named Volume (portabel). Alternativ Bind-Mount: "/pfad/auf/nas/data:/data".
- im-data:/data
environment:
- XDG_CONFIG_HOME=/data
- APP_PORT=8077
restart: unless-stopped
volumes:
im-data:
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Startet die App unveraendert (bindet auf localhost:APP_PORT) und stellt einen
# internen nginx davor, der den Host-Header auf "localhost" umschreibt.
#
# WARUM nginx statt socat: .NET-HttpListener bedient den Prefix
# http://localhost:APP_PORT/ NUR bei Host: localhost. socat (reiner TCP-Proxy)
# reicht den originalen Host-Header durch -> von aussen 404. nginx schreibt den
# Host-Header um -> jeder Zugriffsweg funktioniert, ohne den App-Code zu aendern.
set -e
: "${APP_PORT:=8077}"
# App im Hintergrund starten. -NoBrowser: im Container kein Desktop/open.
pwsh -NoProfile -File /app/Start.ps1 -NoBrowser -Port "${APP_PORT}" &
APP_PID=$!
# Best-effort warten, bis der App-Listener antwortet (IPv6 oder IPv4).
# nginx wuerde sonst kurz 502 liefern, bis die App oben ist.
for i in $(seq 1 30); do
if (exec 3<>"/dev/tcp/::1/${APP_PORT}") 2>/dev/null; then exec 3>&-; break; fi
if (exec 3<>"/dev/tcp/127.0.0.1/${APP_PORT}") 2>/dev/null; then exec 3>&-; break; fi
kill -0 "$APP_PID" 2>/dev/null || { echo "[entrypoint] App-Prozess beendet - Abbruch."; wait "$APP_PID"; exit $?; }
sleep 1
done
echo "[entrypoint] Starte nginx (Host-Rewrite -> localhost) auf :8080"
nginx -g 'daemon off;' &
# Am Leben des App-Prozesses haengen (Container endet, wenn die App endet).
wait "$APP_PID"
+31
View File
@@ -0,0 +1,31 @@
# Interner Reverse-Proxy im Container.
# Zweck: den Host-Header auf "localhost" umschreiben. Die App bindet den
# HttpListener-Prefix http://localhost:8077/ und bedient NUR Requests mit
# Host: localhost - ohne dieses Rewrite kaeme von aussen 404.
worker_processes 1;
events { worker_connections 1024; }
http {
access_log /dev/stdout;
error_log /dev/stderr;
# App-Listener: bevorzugt IPv6-Loopback (so bindet HttpListener "localhost"
# im Container), IPv4 als Fallback.
upstream imapp {
server [::1]:8077;
server 127.0.0.1:8077 backup;
}
server {
listen 8080;
listen [::]:8080;
location / {
proxy_pass http://imapp;
proxy_set_header Host localhost; # <- der entscheidende Punkt
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 300s; # Device-Code-Login/Graph koennen dauern
}
}
}
+9 -3
View File
@@ -38,7 +38,8 @@ Nur nötig, wenn der **Geräte-Tab** (Sync/Reboot/Lock/Diagnose/Wipe/Retire) gen
| 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.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 |
| `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 |
| `DeviceLocalCredential.Read.All` | LAPS-Passwörter lesen |
@@ -84,10 +85,15 @@ sein — sonst scheitert der erste Login (`AADSTS500113` bzw. `AADSTS50011`):
2. **Redirect-URIs** unter *Mobilgerät- und Desktopanwendungen*:
```
https://login.microsoftonline.com/common/oauth2/nativeclient
http://localhost
ms-appx-web://Microsoft.AAD.BrokerPlugin/<CLIENT-ID>
```
- Zeile 1 → **Device-Code-Flow**
- Zeile 2 → **WAM-Broker** (Windows-Anmeldefenster); `<CLIENT-ID>` ist die
- Zeile 2 → **Loopback / interaktiver Browser-Login** — nötig auf **macOS/Linux**
(und überall, wo kein WAM-Broker läuft). Ohne diese URI schlägt der Login mit
`AADSTS50011` fehl (`http://localhost:<Port>` passt nicht). `http://localhost`
ist portagnostisch, deckt also den zufälligen Port ab.
- Zeile 3 → **WAM-Broker** (Windows-Anmeldefenster); `<CLIENT-ID>` ist die
AppId der Registrierung selbst.
3. **Multi-Tenant:** Wird dieselbe App gegen fremde Tenants genutzt, muss
`signInAudience` auf *Accounts in any organizational directory*
+18 -4
View File
@@ -8,7 +8,8 @@
AADSTS500113 / AADSTS50011 scheitert:
* Delegierte Microsoft-Graph-Berechtigungen (nach Bedarf: RW, RO, Geraete)
* "Oeffentliche Clientflows zulassen" (isFallbackPublicClient = true)
* Redirect-URIs fuer Device-Code (nativeclient) und WAM-Broker
* Redirect-URIs fuer Device-Code (nativeclient), Loopback/Browser
(http://localhost, noetig auf macOS/Linux) und WAM-Broker
* optional Admin-Consent
Nutzt nur Microsoft.Graph.Authentication (Invoke-MgGraphRequest) — dieselbe
@@ -33,7 +34,9 @@
.PARAMETER IncludeDeviceActions
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
Erteilt direkt tenantweiten Admin-Consent fuer die gesetzten Scopes.
@@ -78,12 +81,16 @@ $perms = if ($ReadOnly) {
'DeviceManagementApps.ReadWrite.All','DeviceManagementConfiguration.ReadWrite.All','offline_access')
}
if ($IncludeDeviceActions) {
# DeviceManagementServiceConfig.Read.All: Autopilot-Status im Geraete-Detail
# (Profil-Zuweisung / letzter Kontakt) — ansonsten bleibt das Feld leer.
$perms += if ($ReadOnly) {
@('DeviceManagementManagedDevices.Read.All')
@('DeviceManagementManagedDevices.Read.All',
'DeviceManagementServiceConfig.Read.All')
} else {
@('DeviceManagementManagedDevices.Read.All',
'DeviceManagementManagedDevices.ReadWrite.All',
'DeviceManagementManagedDevices.PrivilegedOperations.All')
'DeviceManagementManagedDevices.PrivilegedOperations.All',
'DeviceManagementServiceConfig.Read.All')
}
}
if ($IncludeOffboarding) {
@@ -99,6 +106,11 @@ if ($IncludeOffboarding) {
)
}
$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 ----------------------------------------------------------------
Write-Host "[1/6] Mit Microsoft Graph verbinden (Admin noetig)..." -ForegroundColor Cyan
@@ -149,6 +161,7 @@ if ($ClientId) {
requiredResourceAccess = $requiredResourceAccess
publicClient = @{ redirectUris = @(
'https://login.microsoftonline.com/common/oauth2/nativeclient'
'http://localhost'
"ms-appx-web://Microsoft.AAD.BrokerPlugin/$appId"
) }
}
@@ -173,6 +186,7 @@ if ($ClientId) {
$patch = @{ publicClient = @{ redirectUris = @(
'https://login.microsoftonline.com/common/oauth2/nativeclient'
'http://localhost'
"ms-appx-web://Microsoft.AAD.BrokerPlugin/$appId"
) } }
Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/applications/$objId" `
+1 -1
View File
@@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") {
$env:DOTNET_ROOT = "C:\Program Files\dotnet"
}
$version = "0.1.30"
$version = "0.1.36"
$src = "\\Mac\Home\ClaudePRJ\Intune Manager"
$stage = "$env:TEMP\IntuneManagerStage"
$installerDir = "$src\installer"
+488 -16
View File
@@ -21,6 +21,7 @@ function Invoke-ApiHandler {
"POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body }
"POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body }
"DELETE /api/settings/logo" { return Remove-LogoEndpoint }
"GET /api/update/check" { return Get-UpdateCheckEndpoint }
"POST /api/connect" { return Invoke-ConnectEndpoint }
"POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body }
"POST /api/connect/start" { return Start-DeviceCodeConnect }
@@ -58,10 +59,12 @@ function Invoke-ApiHandler {
"GET /api/policies/exports" { return Get-PolicyExportsEndpoint }
"POST /api/policies/import" { return Import-PoliciesEndpoint -Body $Body }
"POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -Body $Body }
"POST /api/policies/consolidate" { return Invoke-PolicyConsolidateEndpoint -Body $Body }
"POST /api/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint }
"POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body }
"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/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body }
}
@@ -71,6 +74,9 @@ function Invoke-ApiHandler {
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") {
return Get-GroupMembersExportEndpoint -GroupId $matches[1]
}
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/devices/export$") {
return Get-GroupDevicesExportEndpoint -GroupId $matches[1]
}
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") {
return Get-GroupMembersEndpoint -GroupId $matches[1]
@@ -131,6 +137,83 @@ function Invoke-ApiHandler {
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
# ============================================================
@@ -1608,6 +1691,119 @@ function Get-GroupMembersExportEndpoint {
}
}
function Get-GroupDevicesExportEndpoint {
# Loest die User einer Gruppe (inkl. verschachtelter Untergruppen) auf und liefert
# deren Intune-Geraete (Geraete, deren PRIMAERER Benutzer in der Gruppe ist) fuer
# einen CSV-Export. Geraete werden per $batch ueber userId eq '<id>' geholt.
param([string]$GroupId)
$err = Test-Connected
if ($err) { return $err }
if ([string]::IsNullOrWhiteSpace($GroupId)) { return @{ __status = 400; error = "GroupId fehlt" } }
$groupName = $GroupId
try {
$g = Get-GraphGroupById -Id $GroupId -Property @("id","displayName")
if ($g.displayName) { $groupName = [string]$g.displayName }
} catch {
return @{ __status = 404; error = "Gruppe nicht gefunden: $($_.Exception.Message)" }
}
Write-Host "[GRP-DEV-EXPORT] Geraete-Export fuer Gruppe '$groupName' ($GroupId)" -ForegroundColor Cyan
$sw = [System.Diagnostics.Stopwatch]::StartNew()
# User aufloesen + deduplizieren (nur eindeutige Entra-User-Ids)
$allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName)
$seen = @{}
$userIds = [System.Collections.Generic.List[string]]::new()
$userMap = @{} # userId -> @{ Upn; DisplayName } (aus den Gruppen-Mitgliedern)
foreach ($u in $allUsers) {
$uid = [string]$u.Id
if ($uid -and -not $seen.ContainsKey($uid)) {
$seen[$uid] = $true
$userIds.Add($uid)
$userMap[$uid] = @{ Upn = [string]$u.UserPrincipalName; DisplayName = [string]$u.DisplayName }
}
}
if ($userIds.Count -eq 0) {
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,managementAgent'
$batchSize = 20
$devSeen = @{}
$items = [System.Collections.Generic.List[object]]::new()
$ids = $userIds.ToArray()
$script:GrpDevErrors = @()
for ($i = 0; $i -lt $ids.Count; $i += $batchSize) {
$chunk = $ids[$i .. [Math]::Min($i + $batchSize - 1, $ids.Count - 1)]
$requests = @()
for ($j = 0; $j -lt $chunk.Count; $j++) {
# Kanonischer Weg: die managedDevices-Navigation des Users. Zuverlaessiger
# als $filter=userId eq '..' auf /deviceManagement/managedDevices.
$requests += @{ id = [string]($i + $j); method = 'GET'; url = "/users/$($chunk[$j])/managedDevices?`$select=$select" }
}
$body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress
try {
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
foreach ($r in @($resp.responses)) {
if ([int]$r.status -ne 200) {
if (@($script:GrpDevErrors).Count -lt 3) {
$em = $null
try { $em = [string]$r.body.error.message } catch {}
$script:GrpDevErrors += "HTTP $($r.status)$(if ($em) { ": $em" })"
}
continue
}
# Batch-Request-Id -> Index in $ids -> Gruppen-User (fuer UPN/Name-Fallback,
# da managedDevice.userPrincipalName bei Graph oft leer ist).
$owner = $null
$reqIdx = -1; if ([int]::TryParse([string]$r.id, [ref]$reqIdx)) {
if ($reqIdx -ge 0 -and $reqIdx -lt $ids.Count) { $owner = $userMap[$ids[$reqIdx]] }
}
foreach ($d in @($r.body.value)) {
$did = [string]$d.id
if (-not $did -or $devSeen.ContainsKey($did)) { continue }
$devSeen[$did] = $true
$upn = if ($d.userPrincipalName) { [string]$d.userPrincipalName } elseif ($owner) { $owner.Upn } else { '' }
$udn = if ($d.userDisplayName) { [string]$d.userDisplayName } elseif ($owner) { $owner.DisplayName } else { '' }
$items.Add([pscustomobject]@{
Id = $did
DeviceName = [string]$d.deviceName
UserDisplayName = $udn
UserPrincipalName = $upn
OS = [string]$d.operatingSystem
OSVersion = [string]$d.osVersion
ComplianceState = [string]$d.complianceState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = [string]$d.serialNumber
Model = [string]$d.model
Manufacturer = [string]$d.manufacturer
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
})
}
}
} catch {
Write-Host " [GRP-DEV-EXPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow
}
}
$sw.Stop()
Write-Host " -> $($items.Count) Geraete fuer $($userIds.Count) User, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green
return @{
ok = $true
groupId = $GroupId
groupName = $groupName
userCount = $userIds.Count
items = @($items.ToArray())
count = $items.Count
resolvedMs = [int]$sw.ElapsedMilliseconds
errors = @($script:GrpDevErrors)
}
}
# ============================================================
# Users
# ============================================================
@@ -1664,17 +1860,28 @@ function Get-UserMemberOfEndpoint {
# 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 {
param($Query)
$err = Test-Connected
if ($err) { return $err }
$q = [string]$Query.q
$q = ([string]$Query.q) -replace "'", "''" # OData-Escape fuer Apostroph
$os = [string]$Query.os
$compliance = [string]$Query.compliance
$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)
# Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName.
@@ -1683,22 +1890,36 @@ function Search-DevicesEndpoint {
$snItems = @()
if ($q -and $q.Length -ge 2) {
$filters = @()
$nameFilter = "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))"
$filters += $nameFilter
if ($os) { $filters += "operatingSystem eq '$os'" }
if ($compliance) { $filters += "complianceState eq '$compliance'" }
$osComp = @()
if ($os) { $osComp += "operatingSystem eq '$os'" }
if ($compliance) { $osComp += "complianceState eq '$compliance'" }
# Voller Namensfilter vs. nur deviceName. Manche Intune-Backends (DeviceFE)
# unterstuetzen startswith NUR auf deviceName -> bei "Unsupported parameter"
# (400) auf deviceName-only zurueckfallen. Kein $orderby mit $filter.
$nameVariants = @(
"(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))",
"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/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName"
$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)
$snFilters = @("serialNumber eq '$q'")
if ($os) { $snFilters += "operatingSystem eq '$os'" }
if ($compliance) { $snFilters += "complianceState eq '$compliance'" }
$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 {
$snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET
$snItems = @($snResp.value)
@@ -1708,7 +1929,9 @@ function Search-DevicesEndpoint {
if ($os) { $filters += "operatingSystem eq '$os'" }
if ($compliance) { $filters += "complianceState eq '$compliance'" }
$filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' }
$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
$nameItems = @($resp.value)
}
@@ -1728,12 +1951,14 @@ function Search-DevicesEndpoint {
OS = $d.operatingSystem
OSVersion = $d.osVersion
ComplianceState = $d.complianceState
LastSync = $d.lastSyncDateTime
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = $d.serialNumber
Model = $d.model
Manufacturer = $d.manufacturer
EnrolledDateTime = $d.enrolledDateTime
EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
}
}
return @{ items = $items; count = $items.Count }
@@ -1744,12 +1969,38 @@ function Get-DeviceEndpoint {
$err = Test-Connected
if ($err) { return $err }
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,totalStorageSpaceInBytes,freeStorageSpaceInBytes'
$d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET
# 'managementState' entfernt: kein gueltiges $select-Feld auf managedDevices (400).
$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 }
$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 @{
Id = $d.id
DeviceName = $d.deviceName
@@ -1758,12 +2009,14 @@ function Get-DeviceEndpoint {
OS = $d.operatingSystem
OSVersion = $d.osVersion
ComplianceState = $d.complianceState
LastSync = $d.lastSyncDateTime
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = $d.serialNumber
Model = $d.model
Manufacturer = $d.manufacturer
EnrolledDateTime = $d.enrolledDateTime
EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
Imei = $d.imei
WiFiMac = $d.wiFiMacAddress
AzureADDeviceId = $d.azureADDeviceId
@@ -1772,6 +2025,9 @@ function Get-DeviceEndpoint {
OwnerType = $d.managedDeviceOwnerType
TotalStorageGB = $totalGB
FreeStorageGB = $freeGB
InAutopilot = $inAutopilot
AutopilotProfileAssigned = $apProfileAssign
AutopilotLastContacted = $apLastContact
}
}
@@ -2584,11 +2840,28 @@ function Update-AppEndpoint {
# nie heraus — deshalb der Backend-Dialog. Laeuft in einem STA-Runspace, weil
# WinForms-Dialoge zwingend STA brauchen. Owner-Form mit TopMost holt den Dialog
# vor das Browser-Fenster.
# macOS-nativer Auswahldialog via osascript. Liefert den POSIX-Pfad oder ''
# (Abbruch). Der Windows-Filter-String wird ignoriert (AppleScript kennt ihn nicht).
function Show-MacChooser {
param([ValidateSet('file','folder')][string]$Kind, [string]$Title = '')
$prompt = ($Title -replace '"', '\"')
$verb = if ($Kind -eq 'folder') { 'choose folder' } else { 'choose file' }
$script = "POSIX path of ($verb with prompt `"$prompt`")"
try {
$out = & /usr/bin/osascript -e $script 2>$null
return ([string]$out).Trim()
} catch { return '' }
}
function Show-OpenFileDialog {
param(
[string]$Filter = "Alle Dateien (*.*)|*.*",
[string]$Title = "Datei auswaehlen"
)
if (-not (Test-IsWindows)) {
if ($IsMacOS) { return (Show-MacChooser -Kind 'file' -Title $Title) }
throw 'Datei-Dialog wird auf dieser Plattform nicht unterstuetzt - Pfad bitte manuell eingeben.'
}
# Eigener powershell.exe -STA Prozess: in einem In-Process-Runspace blitzt
# der Dialog nur kurz auf (keine echte Windows-Message-Pump, er bleibt nicht
# modal). Ein separater STA-Konsolen-Prozess hat einen vollwertigen
@@ -2650,6 +2923,10 @@ function Invoke-FilePickerEndpoint {
function Show-OpenFolderDialog {
# Wie Show-OpenFileDialog, aber FolderBrowserDialog in einem STA-Prozess.
param([string]$Title = "Ordner auswaehlen")
if (-not (Test-IsWindows)) {
if ($IsMacOS) { return (Show-MacChooser -Kind 'folder' -Title $Title) }
throw 'Ordner-Dialog wird auf dieser Plattform nicht unterstuetzt - Pfad bitte manuell eingeben.'
}
$tEsc = $Title -replace "'", "''"
$inner = @"
`$ProgressPreference = 'SilentlyContinue'
@@ -3470,3 +3747,198 @@ footer code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;bac
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
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
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
}
}
+26 -2
View File
@@ -26,13 +26,37 @@ function ConvertTo-IsoDate {
return $s
}
# ============================================================
# Plattform-Helfer: App-Daten-Verzeichnis (Settings, Exporte).
# Windows: %APPDATA%\IntuneAppManager-Web (unveraendert).
# macOS: ~/Library/Application Support/IntuneAppManager-Web
# Linux: $XDG_CONFIG_HOME bzw. ~/.config/IntuneAppManager-Web
# ============================================================
function Test-IsWindows {
# PS5.1 kennt $IsWindows nicht -> $env:OS faengt das ab.
return (($env:OS -eq 'Windows_NT') -or ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows)
}
function Get-AppDataDir {
$base = if ($env:APPDATA) {
$env:APPDATA
} elseif ($IsMacOS) {
Join-Path $HOME 'Library/Application Support'
} elseif ($IsLinux) {
if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME '.config' }
} else {
[Environment]::GetFolderPath('ApplicationData')
}
return (Join-Path $base 'IntuneAppManager-Web')
}
# ============================================================
# Settings: editierbare Konfiguration, persistiert als JSON
# unter %APPDATA%\IntuneAppManager-Web\settings.json
# unter <AppData>\IntuneAppManager-Web\settings.json
# ============================================================
function Get-SettingsPath {
$dir = Join-Path $env:APPDATA "IntuneAppManager-Web"
$dir = Get-AppDataDir
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
return Join-Path $dir "settings.json"
}
+151 -5
View File
@@ -54,7 +54,101 @@ function Search-OffboardDevicesEndpoint {
}
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.
$reqs = @()
@@ -77,9 +171,23 @@ function Search-OffboardDevicesEndpoint {
$items = @()
$idx = 0
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] }
$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]@{
deviceName = [string]$d.deviceName
serialNumber = [string]$d.serialNumber
@@ -94,13 +202,39 @@ function Search-OffboardDevicesEndpoint {
entraObjectId = if ($entra) { [string]$entra.id } else { '' }
entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null }
autopilotId = if ($autop) { [string]$autop.id } else { '' }
autopilotNote = $apNote
inIntune = $true
inEntra = [bool]$entra
inAutopilot = [bool]$autop
}
$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:
@@ -233,6 +367,18 @@ function Invoke-OffboardExecuteEndpoint {
}
}
$okCount = @($results | Where-Object { $_.success }).Count
$errCount = $results.Count - $okCount
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 }
}
+398 -18
View File
@@ -14,8 +14,7 @@
# JSON-Inhalt (Frontend-Upload) oder eine Datei aus dem Server-Archiv.
function Get-PolicyExportDir {
$dir = Join-Path $env:APPDATA 'IntuneAppManager-Web'
$dir = Join-Path $dir 'policy-exports'
$dir = Join-Path (Get-AppDataDir) 'policy-exports'
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
return $dir
}
@@ -154,14 +153,44 @@ function Get-GraphPolicyList {
param([Parameter(Mandatory=$true)][string]$Type)
$cfg = Get-PolicyTypeConfig $Type
if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" }
$raw = Get-GraphPaged -Uri "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)"
# Zuweisungen gleich mitladen ($expand=assignments), damit die Liste je Policy
# Anzahl + aufgeloeste Ziele zeigen kann. Nicht jede deviceManagement-Collection
# unterstuetzt $expand=assignments (z.B. groupPolicyConfigurations lehnt es teils
# mit 400 ab) -> im Fehlerfall ohne Zuweisungen laden, damit der Typ ueberhaupt
# erscheint (und exportierbar bleibt).
$listBase = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)"
try {
$raw = Get-GraphPaged -Uri ($listBase + '?$expand=assignments')
} catch {
Write-Host " [POLICIES] $($cfg.Key): `$expand=assignments nicht unterstuetzt -> lade ohne Zuweisungen ($($_.Exception.Message))" -ForegroundColor DarkYellow
$raw = Get-GraphPaged -Uri $listBase
}
$items = @()
$needGroups = @{} # eindeutige Gruppen-Ids ueber alle Policies (fuer 1 Bulk-Lookup)
foreach ($r in $raw) {
$id = Get-PolicyProp $r 'id'
if (-not $id) { continue }
$name = Get-PolicyProp $r $cfg.NameField
if (-not $name) { $name = Get-PolicyProp $r 'displayName' }
if (-not $name) { $name = Get-PolicyProp $r 'name' }
$asg = @()
foreach ($a in @(Get-PolicyProp $r 'assignments')) {
$t = Get-PolicyProp $a 'target'
if (-not $t) { continue }
$ot = [string](Get-PolicyProp $t '@odata.type')
$gid = [string](Get-PolicyProp $t 'groupId')
$entry = $null
if ($ot -like '*exclusionGroupAssignmentTarget') { $entry = [ordered]@{ mode = 'exclude'; kind = 'group'; groupId = $gid } }
elseif ($ot -like '*groupAssignmentTarget') { $entry = [ordered]@{ mode = 'include'; kind = 'group'; groupId = $gid } }
elseif ($ot -like '*allDevicesAssignmentTarget') { $entry = [ordered]@{ mode = 'include'; kind = 'allDevices'; groupId = '' } }
elseif ($ot -like '*allLicensedUsersAssignmentTarget') { $entry = [ordered]@{ mode = 'include'; kind = 'allUsers'; groupId = '' } }
if ($entry) {
if ($gid) { $needGroups[$gid] = $true }
$asg += $entry
}
}
$items += [ordered]@{
id = [string]$id
name = [string]$name
@@ -170,6 +199,25 @@ function Get-GraphPolicyList {
platform = Get-PolicyPlatformLabel -Raw $r -Type $cfg.Key
odataType = [string](Get-PolicyProp $r '@odata.type')
lastModifiedDateTime = Get-PolicyProp $r 'lastModifiedDateTime'
assignments = $asg
assignmentCount = @($asg).Count
}
}
# Gruppennamen in EINEM Bulk-Lookup aufloesen (bereits bekannte aus dem Cache).
$lookup = @{}
try {
foreach ($g in @($script:State.Groups)) { if ($g.Id) { $lookup[[string]$g.Id] = [string]$g.DisplayName } }
foreach ($g in @($script:State.RpaGroups)) { if ($g.Id) { $lookup[[string]$g.Id] = [string]$g.DisplayName } }
} catch {}
$unknown = [string[]]@($needGroups.Keys | ForEach-Object { [string]$_ } | Where-Object { $_ -and -not $lookup.ContainsKey($_) })
if ($unknown.Count -gt 0) { try { Resolve-GroupNamesBulk -Ids $unknown -Lookup $lookup } catch {} }
foreach ($it in $items) {
foreach ($a in @($it.assignments)) {
if ($a.kind -eq 'group') {
$gid = [string]$a.groupId
$a.groupName = if ($lookup.ContainsKey($gid)) { $lookup[$gid] } else { $gid }
}
}
}
return $items
@@ -188,10 +236,24 @@ function Get-GraphPolicyDetail {
# (definitionValues inkl. Definition + Presentation-Werten) separat expandieren.
# -AsRawJson durchgaengig: bewahrt Ein-Element-Collections als Array (PS-5.1-
# Hashtable-Modus wuerde sie skalarisieren -> 400 beim Re-Import).
# WICHTIG: presentationValues($expand=presentation) waere ein VERSCHACHTELTER
# Expand (Tiefe 2) -> Graph lehnt mit 400 ab ("$expand path too deep, max 1").
# Deshalb definitionValues nur mit 'definition' expandieren (Tiefe 1) und die
# presentationValues je definitionValue separat mit 'presentation' nachladen.
if ($cfg.Key -eq 'administrativetemplate') {
$base = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" -Method GET -AsRawJson
$dvUri = "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id/definitionValues?`$expand=definition(`$select=id,classType,displayName,policyType,version),presentationValues(`$expand=presentation)"
$cfgRoot = "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id"
$base = Invoke-MgGraphRequestRetry -Uri $cfgRoot -Method GET -AsRawJson
$dvUri = "$cfgRoot/definitionValues?`$expand=definition(`$select=id,classType,displayName,categoryPath,policyType,version)"
$dvs = @(Get-GraphPaged -Uri $dvUri -AsRawJson)
foreach ($dv in $dvs) {
if (-not $dv) { continue }
$dvId = [string](Get-PolicyProp $dv 'id')
if (-not $dvId) { continue }
$pvUri = "$cfgRoot/definitionValues/$dvId/presentationValues?`$expand=presentation"
$pvs = @(Get-GraphPaged -Uri $pvUri -AsRawJson)
if ($dv -is [System.Collections.IDictionary]) { $dv['presentationValues'] = $pvs }
else { $dv | Add-Member -NotePropertyName presentationValues -NotePropertyValue $pvs -Force }
}
if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs }
else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force }
return $base
@@ -230,7 +292,7 @@ function ConvertTo-ImportBody {
# Arrays. Idempotent: bereits korrekte Arrays bleiben unveraendert.
function Repair-SettingsCatalogArrays {
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
# geringer ConvertTo-Json-Tiefe hat solche (tief liegenden) Objekte zu ".ToString()"
# stringifiziert -> "System.Collections.Hashtable". Graph lehnt das ab
@@ -422,6 +484,75 @@ function Import-GraphSettingsCatalogPolicy {
return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json'
}
# Loest eine ingestete (custom) ADMX-Definition im AKTUELLEN Ziel-Tenant auf.
# Custom-ADMX-IDs sind tenant-spezifisch -> Binding per Export-Id scheitert (404).
# Match ueber stabile Merkmale: displayName + classType (+ categoryPath).
# Voraussetzung: die ADMX ist im Ziel-Tenant importiert. Sonst $null.
function Resolve-TargetGpDefinition {
param($SrcDefinition)
$dn = [string](Get-PolicyProp $SrcDefinition 'displayName')
$ct = [string](Get-PolicyProp $SrcDefinition 'classType')
$cat = [string](Get-PolicyProp $SrcDefinition 'categoryPath')
if (-not $dn) { return $null }
$dnEsc = $dn -replace "'", "''"
$base = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions'
$cands = @()
try {
$uri = "$base`?`$filter=" + [uri]::EscapeDataString("displayName eq '$dnEsc'")
$cands = @(Get-GraphPaged -Uri $uri -AsRawJson)
} catch { $cands = @() }
# Fallback, falls $filter auf displayName nicht unterstuetzt wird: ueber categoryPath.
if ($cands.Count -eq 0 -and $cat) {
try {
$catEsc = $cat -replace "'", "''"
$uri2 = "$base`?`$filter=" + [uri]::EscapeDataString("categoryPath eq '$catEsc'")
$cands = @(Get-GraphPaged -Uri $uri2 -AsRawJson) | Where-Object { [string](Get-PolicyProp $_ 'displayName') -eq $dn }
} catch { $cands = @() }
}
if (@($cands).Count -eq 0) { return $null }
# classType zuerst eingrenzen (User- vs. Device-Variante nie verwechseln),
# dann innerhalb dessen categoryPath bevorzugen; sonst erster Treffer.
$pool = @($cands) | Where-Object { (-not $ct) -or ([string](Get-PolicyProp $_ 'classType') -eq $ct) }
if (@($pool).Count -eq 0) { $pool = @($cands) }
$match = @($pool) | Where-Object { $cat -and ([string](Get-PolicyProp $_ 'categoryPath') -eq $cat) } | Select-Object -First 1
if (-not $match) { $match = @($pool) | Select-Object -First 1 }
return $match
}
# Findet zu einer Quell-Presentation die passende Ziel-Presentation-Id:
# 1) per label + @odata.type, 2) Fallback: i-te Ziel-Presentation gleichen Typs
# (in Definitions-Reihenfolge; $Counter zaehlt je Typ mit).
function Resolve-TargetPresentationId {
param($TargetPres, $SrcPresentation, [hashtable]$Counter)
# @odata.type-Format normalisieren: Graph liefert mal '#microsoft.graph.X',
# mal 'microsoft.graph.X' -> fuehrendes '#' weg, klein. Sonst schlaegt der
# String-Vergleich fehl und Text-Presentations werden nicht zugeordnet.
$norm = { param($t) ([string]$t).TrimStart('#').ToLower() }
$srcLabel = [string](Get-PolicyProp $SrcPresentation 'label')
$srcType = & $norm (Get-PolicyProp $SrcPresentation '@odata.type')
# 1) label + Typ
if ($srcLabel) {
$m = @($TargetPres) | Where-Object {
([string](Get-PolicyProp $_ 'label') -eq $srcLabel) -and
((& $norm (Get-PolicyProp $_ '@odata.type')) -eq $srcType)
} | Select-Object -First 1
if ($m) { return [string](Get-PolicyProp $m 'id') }
}
# 2) i-te Ziel-Presentation gleichen Typs (in Reihenfolge).
# WICHTIG: Ergebnis in @() zwingen - bei EINEM Treffer liefert Where-Object ein
# Einzelobjekt; ist das eine Hashtable (PS7 -AsHashtable), waere $x[0] eine
# Schluessel-Suche nach Key 0 (=> $null) statt Array-Index -> Fehlmatch.
$idx = 0; if ($Counter.ContainsKey($srcType)) { $idx = [int]$Counter[$srcType] }
$sameType = @(@($TargetPres) | Where-Object { (& $norm (Get-PolicyProp $_ '@odata.type')) -eq $srcType })
$Counter[$srcType] = $idx + 1
if ($idx -lt $sameType.Count) { return [string](Get-PolicyProp $sameType[$idx] 'id') }
# 3) Letzter Fallback: hat die Ziel-Definition genau EINE Presentation, nimm sie
# (deckt Ein-Feld-Settings wie Textboxen zuverlaessig ab).
$allTgt = @($TargetPres)
if ($allTgt.Count -eq 1) { return [string](Get-PolicyProp $allTgt[0] 'id') }
return $null
}
function Import-GraphAdministrativeTemplate {
param([Parameter(Mandatory=$true)]$Policy)
@@ -442,23 +573,53 @@ function Import-GraphAdministrativeTemplate {
if (-not $newId) { throw 'Anlegen der Administrative-Vorlage-Huelle lieferte keine Id.' }
# 2) Jeden definitionValue einzeln anhaengen. Definition + Presentations werden
# per @odata.bind referenziert — die IDs eingebauter ADMX-Vorlagen sind
# tenantuebergreifend identisch, daher tenantunabhaengig einsetzbar.
# per @odata.bind referenziert. EINGEBAUTE ADMX-Vorlagen (policyType
# 'admxBacked') haben tenantuebergreifend identische IDs -> Export-Id direkt
# verwendbar. INGESTETE/CUSTOM ADMX ('admxIngested') hat tenant-spezifische
# IDs -> im Ziel-Tenant ueber displayName/classType/categoryPath neu aufloesen
# (setzt voraus, dass dieselbe ADMX im Ziel importiert ist; sonst 404).
$errors = @()
foreach ($dv in @(Get-PolicyProp $Policy 'definitionValues')) {
if (-not $dv) { continue }
$def = Get-PolicyProp $dv 'definition'
$defId = [string](Get-PolicyProp $def 'id')
if (-not $defId) {
$errors += 'definitionValue ohne Definition-Id uebersprungen'
$srcDefId = [string](Get-PolicyProp $def 'id')
$defName = [string](Get-PolicyProp $def 'displayName'); if (-not $defName) { $defName = $srcDefId }
if (-not $srcDefId) { $errors += 'definitionValue ohne Definition-Id uebersprungen'; continue }
$ingested = ([string](Get-PolicyProp $def 'policyType') -eq 'admxIngested')
$defId = $srcDefId
$tgtPres = $null
if ($ingested) {
$tgtDef = Resolve-TargetGpDefinition $def
if (-not $tgtDef) {
$errors += "${defName}: ingestete ADMX-Definition im Ziel-Tenant nicht gefunden - die passende ADMX muss dort importiert sein"
continue
}
$defId = [string](Get-PolicyProp $tgtDef 'id')
try { $tgtPres = @(Get-GraphPaged -Uri "$defRoot/$defId/presentations" -AsRawJson) } catch { $tgtPres = @() }
}
$presVals = @()
$typeCounter = @{}
$presFailed = $false
$presDiag = ''
foreach ($pv in @(Get-PolicyProp $dv 'presentationValues')) {
if (-not $pv) { continue }
$pres = Get-PolicyProp $pv 'presentation'
if ($ingested) {
$presId = Resolve-TargetPresentationId -TargetPres $tgtPres -SrcPresentation $pres -Counter $typeCounter
# Presentation nicht zuordenbar (andere ADMX-Version im Ziel): die GANZE
# Einstellung ueberspringen, statt mit unvollstaendigem Body ein 400 zu
# provozieren. Zur Diagnose Quelle + Ziel-Presentations anhaengen.
if (-not $presId) {
$srcI = "$([string](Get-PolicyProp $pres '@odata.type'))|label='$([string](Get-PolicyProp $pres 'label'))'"
$tgtI = (@($tgtPres) | ForEach-Object { "$([string](Get-PolicyProp $_ '@odata.type'))|label='$([string](Get-PolicyProp $_ 'label'))'" }) -join ' ; '
$presDiag = "Quelle[$srcI] Ziel[$tgtI]"
$presFailed = $true; break
}
} else {
$presId = [string](Get-PolicyProp $pres 'id')
}
$entry = [ordered]@{
'@odata.type' = [string](Get-PolicyProp $pv '@odata.type')
'presentation@odata.bind' = "$defRoot('$defId')/presentations('$presId')"
@@ -470,6 +631,10 @@ function Import-GraphAdministrativeTemplate {
}
$presVals += $entry
}
if ($presFailed) {
$errors += "${defName}: Presentation nicht zuordenbar - uebersprungen. $presDiag"
continue
}
$body = [ordered]@{
enabled = [bool](Get-PolicyProp $dv 'enabled')
@@ -481,8 +646,7 @@ function Import-GraphAdministrativeTemplate {
} catch {
$m = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {}
$dn = [string](Get-PolicyProp $def 'displayName'); if (-not $dn) { $dn = $defId }
$errors += "${dn}: $m"
$errors += "${defName}: $m"
}
}
@@ -553,10 +717,16 @@ function Export-PoliciesEndpoint {
$files = @()
$exports = @()
$errors = @()
foreach ($id in $ids) {
$policy = $null
try { $policy = Get-GraphPolicyDetail -Type $type -Id ([string]$id) } catch {}
if (-not $policy) { continue }
try { $policy = Get-GraphPolicyDetail -Type $type -Id ([string]$id) }
catch {
$errors += @{ id = [string]$id; error = $_.Exception.Message }
Write-Host " [EXPORT] $type/$id fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
continue
}
if (-not $policy) { $errors += @{ id = [string]$id; error = 'Leere Antwort von Graph' }; continue }
$displayName = Get-PolicyDisplayName $policy
$exportData = [ordered]@{
@@ -584,7 +754,7 @@ function Export-PoliciesEndpoint {
}
}
return @{ ok = $true; exportedCount = $exports.Count; files = @($files); exports = @($exports) }
return @{ ok = $true; exportedCount = $exports.Count; files = @($files); exports = @($exports); errors = @($errors) }
}
# Liste des Server-Archivs (fuer optionalen Server-seitigen Re-Import).
@@ -706,27 +876,82 @@ function Invoke-PolicyAssignEndpoint {
$name = [string](Get-PolicyProp $it 'policyName')
$include = @(Get-PolicyProp $it 'include') | 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
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 (@($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 }
continue
}
$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) {
$key = "#microsoft.graph.groupAssignmentTarget|$g"
if ($seen[$key]) { continue }
$seen[$key] = $true
$assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } }
}
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 } }
}
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
$uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assign"
try {
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 {
$m = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {}
@@ -738,6 +963,161 @@ function Invoke-PolicyAssignEndpoint {
return @{ ok = $true; assignedCount = $ok; results = @($results) }
}
# ============================================================
# Settings-Catalog-Policies zu EINER neuen Policy zusammenfuehren
# ============================================================
# settingDefinitionId eines Settings-Elements (Wrapper { settingInstance, id }).
function Get-SettingDefinitionId {
param($SettingElement)
$si = Get-PolicyProp $SettingElement 'settingInstance'
if (-not $si) { $si = $SettingElement }
return [string](Get-PolicyProp $si 'settingDefinitionId')
}
# Kanonische, stabil sortierte JSON-Darstellung der settingInstance -> Wert-Vergleich
# fuer die Konflikt-Erkennung (gleiche Definition, unterschiedlicher Wert = Konflikt).
function Get-SettingCanonicalJson {
param($SettingElement)
$si = Get-PolicyProp $SettingElement 'settingInstance'
if (-not $si) { $si = $SettingElement }
return ((ConvertTo-StableObject $si) | ConvertTo-Json -Depth 50 -Compress)
}
# Body: { ids:[...], mode:"preview"|"create", name?, description?, resolutions?:{ <defId>:<sourceId> } }
# Nur Settings Catalog. Fuehrt die 'settings' mehrerer Policies zu einer neuen zusammen.
# Gleiche settingDefinitionId + gleicher Wert -> einmal uebernommen. Gleiche Definition,
# anderer Wert -> Konflikt: Default gewinnt die zuerst gewaehlte Policy, per 'resolutions'
# ueberschreibbar. Import erfolgt als Neuanlage (nie Ueberschreiben).
function Invoke-PolicyConsolidateEndpoint {
param($Body)
try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } }
$ids = @(Get-PolicyProp $Body 'ids') | Where-Object { $_ }
if (@($ids).Count -lt 2) { return @{ __status = 400; error = 'Bitte mindestens zwei Settings-Catalog-Policies auswaehlen.' } }
$mode = [string](Get-PolicyProp $Body 'mode'); if (-not $mode) { $mode = 'preview' }
$resolutions = Get-PolicyProp $Body 'resolutions'
# Details laden (Reihenfolge = Auswahlreihenfolge = Default-Konfliktgewinner)
$sources = @()
foreach ($id in $ids) {
$detail = $null
try { $detail = Get-GraphPolicyDetail -Type 'settingscatalog' -Id ([string]$id) } catch {}
if (-not $detail) { return @{ __status = 400; error = "Policy $id konnte nicht geladen werden." } }
$sources += @{
id = [string]$id
name = [string](Get-PolicyProp $detail 'name')
platforms = [string](Get-PolicyProp $detail 'platforms')
technologies = [string](Get-PolicyProp $detail 'technologies')
settings = @(Get-PolicyProp $detail 'settings')
}
}
# Plattform muss uebereinstimmen - sonst kein sinnvoller Merge.
$platforms = @($sources | ForEach-Object { $_.platforms } | Where-Object { $_ } | Sort-Object -Unique)
if ($platforms.Count -gt 1) {
return @{ __status = 400; error = "Unterschiedliche Plattformen ($($platforms -join ', ')) - Zusammenfuehren nicht moeglich." }
}
$mergedPlatform = if ($platforms.Count -ge 1) { $platforms[0] } else { 'windows10' }
# Technologies vereinen (Union).
$techSet = [ordered]@{}
foreach ($s in $sources) { foreach ($t in ($s.technologies -split ',')) { $tt = $t.Trim(); if ($tt) { $techSet[$tt] = $true } } }
$mergedTech = (@($techSet.Keys) -join ','); if (-not $mergedTech) { $mergedTech = 'mdm' }
# Nach settingDefinitionId gruppieren (Reihenfolge der Definitionen beibehalten).
$groups = [ordered]@{}
foreach ($s in $sources) {
foreach ($el in @($s.settings)) {
if (-not $el) { continue }
$defId = Get-SettingDefinitionId $el
if (-not $defId) { continue }
if (-not $groups.Contains($defId)) { $groups[$defId] = @() }
$groups[$defId] += @{ sourceId = $s.id; sourceName = $s.name; element = $el; canon = (Get-SettingCanonicalJson $el) }
}
}
$mergedSettings = @()
$conflicts = @()
foreach ($defId in @($groups.Keys)) {
$entries = @($groups[$defId])
$distinct = @($entries | Group-Object -Property { $_.canon })
if ($distinct.Count -eq 1) {
$mergedSettings += $entries[0].element
continue
}
# Konflikt: Gewinner bestimmen (resolutions[defId] = sourceId, sonst erste Quelle).
$resSource = if ($resolutions) { [string](Get-PolicyProp $resolutions $defId) } else { '' }
$chosen = $null
if ($resSource) { $chosen = @($entries | Where-Object { $_.sourceId -eq $resSource })[0] }
if (-not $chosen) { $chosen = $entries[0] }
$mergedSettings += $chosen.element
$conflicts += @{
settingDefinitionId = $defId
chosenSourceId = $chosen.sourceId
variants = @($distinct | ForEach-Object {
@{
sourceIds = @($_.Group | ForEach-Object { $_.sourceId })
sourceNames = @($_.Group | ForEach-Object { $_.sourceName } | Select-Object -Unique)
}
})
}
}
if ($mode -ne 'create') {
return @{
ok = $true
mode = 'preview'
platform = $mergedPlatform
technologies = $mergedTech
totalSettings = @($mergedSettings).Count
conflictCount = @($conflicts).Count
conflicts = @($conflicts)
sources = @($sources | ForEach-Object { @{ id = $_.id; name = $_.name; settingCount = @($_.settings).Count } })
}
}
# --- create ---
if ($script:State.ReadOnly) { return @{ __status = 403; error = 'Read-Only-Modus: Zusammenfuehren ist deaktiviert.' } }
$name = [string](Get-PolicyProp $Body 'name')
if ([string]::IsNullOrWhiteSpace($name)) { return @{ __status = 400; error = 'Name fuer die neue Policy fehlt.' } }
$desc = [string](Get-PolicyProp $Body 'description')
# settings fuer den POST vorbereiten: Wrapper mit @odata.type, read-only 'id' weg,
# Arrays reparieren (gleiche Behandlung wie beim Import).
$outSettings = @()
foreach ($el in $mergedSettings) {
$si = Get-PolicyProp $el 'settingInstance'
if (-not $si) { $si = $el }
$outSettings += [ordered]@{
'@odata.type' = '#microsoft.graph.deviceManagementConfigurationSetting'
settingInstance = $si
}
}
$outSettings = @(Repair-SettingsCatalogArrays $outSettings)
$newBody = [ordered]@{
name = $name
description = $desc
platforms = $mergedPlatform
technologies = $mergedTech
templateReference = @{ templateFamily = 'none'; templateId = '' }
settings = $outSettings
}
$json = $newBody | ConvertTo-Json -Depth 50
if ($json -match 'System\.Collections\.Hashtable|System\.Object\[\]') {
return @{ __status = 500; error = 'Interner Serialisierungsfehler beim Zusammenfuehren (stringifizierte Objekte).' }
}
try {
$created = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json'
return @{ ok = $true; mode = 'create'; newId = [string](Get-PolicyProp $created 'id'); name = $name; settingsCount = @($outSettings).Count; conflictCount = @($conflicts).Count }
} catch {
$msg = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {}
return @{ __status = 500; error = $msg }
}
}
# ============================================================
# Policy-Snapshot nach Git (voller Export, stabile Dateinamen)
# ============================================================
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
#
# Intune Manager - Start auf macOS (funktioniert auch unter Linux).
#
# Verwendung:
# - Im Finder doppelklicken (Datei muss ausfuehrbar sein: chmod +x start-macos.command)
# - oder im Terminal: ./start-macos.command
# ./start-macos.command -Port 8090
# ./start-macos.command -NoBrowser
#
# Alle Argumente werden unveraendert an Start.ps1 durchgereicht.
#
set -euo pipefail
# In das Verzeichnis dieses Scripts wechseln (= Repo-Root), egal von wo gestartet.
cd "$(dirname "$0")"
echo "=============================================="
echo " Intune Manager - Start (macOS)"
echo "=============================================="
# 1) PowerShell 7 vorhanden?
if ! command -v pwsh >/dev/null 2>&1; then
echo "FEHLER: PowerShell 7 (pwsh) ist nicht installiert."
echo " Installieren mit: brew install --cask powershell"
exit 1
fi
# 2) Graph-Auth-Modul vorhanden? Sonst einmalig installieren.
if ! pwsh -NoProfile -Command "if (Get-Module -ListAvailable Microsoft.Graph.Authentication) { exit 0 } else { exit 1 }"; then
echo "Installiere Microsoft.Graph.Authentication (einmalig, CurrentUser)..."
pwsh -NoProfile -Command "Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force"
fi
# 3) Port aus den Argumenten lesen (Default 8077) - nur fuer die Port-Freigabe unten.
PORT=8077
args=("$@")
for ((i=0; i<${#args[@]}; i++)); do
if [[ "${args[$i]}" == "-Port" && $((i+1)) -lt ${#args[@]} ]]; then
PORT="${args[$((i+1))]}"
fi
done
# 4) Belegten Port freigeben (auf macOS gibt es keinen automatischen Instanz-Kill).
if lsof -ti ":$PORT" >/dev/null 2>&1; then
echo "Port $PORT ist belegt - beende alte Instanz..."
lsof -ti ":$PORT" | xargs kill 2>/dev/null || true
sleep 1
fi
# 5) Server starten. Argumente 1:1 an Start.ps1 weitergeben.
echo "Starte Server auf http://localhost:$PORT/ (Beenden mit Ctrl+C)"
echo ""
exec pwsh -NoProfile -File ./Start.ps1 "$@"
+89
View File
@@ -0,0 +1,89 @@
# Pester-Tests fuer die Payload-Transformationen in PolicyIO.ps1.
# Diese reinen Funktionen sind genau die Bug-Klasse, die uns mehrfach getroffen
# hat: der JSON-Roundtrip unter Windows PowerShell 5.1 entpackt Ein-Element-Arrays
# zu Einzelobjekten -> Graph antwortet mit 400 "... does not match schema".
#
# Ausfuehren: Invoke-Pester .\tests
# (klassische Pester-3/4-Syntax, passend zur mit Windows gelieferten Version.)
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $here '..\src\PolicyIO.ps1')
Describe 'Repair-SettingsCatalogArrays' {
It 'macht aus einem skalaren choiceSettingCollectionValue ein Array' {
# Genau der Fall aus dem gefixten Defender-Antivirus-Import.
$node = @{
settingInstance = @{
'@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance'
choiceSettingCollectionValue = @{ value = 'x'; children = @() }
}
}
$fixed = Repair-SettingsCatalogArrays $node
$ccv = $fixed.settingInstance.choiceSettingCollectionValue
($ccv -is [System.Collections.IList]) | Should Be $true
@($ccv).Count | Should Be 1
}
It 'erzwingt auch group- und simpleSettingCollectionValue als Array' {
$node = @{
settingInstance = @{
groupSettingCollectionValue = @{ children = @() }
simpleSettingCollectionValue = @{ value = 1 }
}
}
$fixed = Repair-SettingsCatalogArrays $node
($fixed.settingInstance.groupSettingCollectionValue -is [System.Collections.IList]) | Should Be $true
($fixed.settingInstance.simpleSettingCollectionValue -is [System.Collections.IList]) | Should Be $true
}
It 'macht aus einem einzelnen settings-Objekt ein Array' {
$node = @{ settings = @{ id = '0'; settingInstance = @{} } }
$fixed = Repair-SettingsCatalogArrays $node
($fixed.settings -is [System.Collections.IList]) | Should Be $true
@($fixed.settings).Count | Should Be 1
}
It 'ersetzt null-Collections durch ein leeres Array (nicht {})' {
$node = @{ settingInstance = @{ children = $null } }
$fixed = Repair-SettingsCatalogArrays $node
($fixed.settingInstance.children -is [System.Collections.IList]) | Should Be $true
@($fixed.settingInstance.children).Count | Should Be 0
}
It 'ist idempotent: ein bereits korrektes Array bleibt ein Array gleicher Laenge' {
$node = @{ choiceSettingCollectionValue = @( @{ value = 'a' }, @{ value = 'b' } ) }
$fixed = Repair-SettingsCatalogArrays $node
($fixed.choiceSettingCollectionValue -is [System.Collections.IList]) | Should Be $true
@($fixed.choiceSettingCollectionValue).Count | Should Be 2
}
It 'filtert leere String-Elemente aus einer Collection heraus' {
$node = @{ children = @('', @{ value = 'x' }, ' ') }
$fixed = Repair-SettingsCatalogArrays $node
@($fixed.children).Count | Should Be 1
}
It 'setzt einen zu String stringifizierten *TemplateReference auf null' {
$node = @{ settingInstance = @{ settingInstanceTemplateReference = 'System.Collections.Hashtable' } }
$fixed = Repair-SettingsCatalogArrays $node
$fixed.settingInstance.settingInstanceTemplateReference | Should Be $null
}
}
Describe 'Remove-PolicyNullProps' {
It 'entfernt Properties mit null-Wert' {
$node = @{ keep = 'a'; drop = $null }
$fixed = Remove-PolicyNullProps $node
$fixed.ContainsKey('keep') | Should Be $true
$fixed.ContainsKey('drop') | Should Be $false
}
It 'behaelt Array-Properties als Array' {
$node = @{ values = @( @{ a = 1 } ) }
$fixed = Remove-PolicyNullProps $node
($fixed.values -is [System.Collections.IList]) | Should Be $true
@($fixed.values).Count | Should Be 1
}
}
+712 -43
View File
File diff suppressed because it is too large Load Diff
+57 -1
View File
@@ -16,13 +16,16 @@ Web-Frontend für Microsoft Intune. Lädt Apps und Gruppen aus deinem Tenant üb
## Navigation
Der Header enthält drei Tabs:
Der Header enthält diese Tabs:
| Tab | Funktion |
|---|---|
| **App Management** | Haupt-Workflow: Apps laden, Gruppen zuweisen und entfernen |
| **Group Management** | Mitglieder anzeigen, exportieren, hinzufügen, CSV-Import |
| **App Report** | Installationszähler pro App, Geräte-Export als CSV |
| **Geräte** | Geräte suchen/filtern, Verwaltungsart (Intune/Co-Managed), Detail-Panel, Aktionen, Bulk-Offboarding |
| **Policies** | Policies exportieren/importieren, konsolidieren, **Gruppen zuweisen (Bulk)**, Git-Snapshot |
| **Offboarding** | Geräte über Intune + Autopilot + Entra entfernen, Recovery-Keys sichern |
> Im **Read-Only-Modus** (Fallback auf eingeschränkte App-ID) sind die Tabs *Benutzer hinzufügen* und *CSV-Import* ausgeblendet. Alle Schreib-Buttons verschwinden automatisch.
@@ -141,6 +144,59 @@ Ohne Header: erste Spalte mit `@` wird verwendet. Quoted fields und `,`/`;`/Tab
---
## Geräte
Geräte des Tenants suchen, filtern und verwalten.
- **Suche & Filter** — Freitext (Gerätename, Benutzer, UPN, Seriennummer), OS- und Compliance-Filter. Zusätzlich lässt sich nach **Gruppe** filtern (alle Geräte der Gruppenmitglieder).
- **Spalte „Verwaltung"** — zeigt je Gerät, ob es **Intune** (reines MDM) oder **Co-Managed** (ConfigMgr + Intune) ist. Sortierbar, auch im CSV-Export enthalten.
- **Detail-Panel** (Klick auf eine Zeile) — Benutzer, OS, Compliance, Speicher, Seriennummer, Join-/Besitzertyp und der **Autopilot-Status** (registriert, Profil zugewiesen am, letzter Kontakt). Ein exaktes Autopilot-Importdatum liefert Microsoft Graph nicht — angezeigt werden die verfügbaren Zeitstempel.
- **Geräte-Aktionen** *(nur Read/Write)* — Sync, Neustart, Remote-Lock, Diagnose, BitLocker-Key-Rotation sowie Wipe / Retire / Autopilot-Reset (Letztere brauchen `…PrivilegedOperations.All`).
- **CSV-Export** — die aktuell gefilterte Liste als CSV.
- **Bulk-Offboarding** — Geräte per Checkbox wählen und über **„Offboarden"** direkt in den Offboarding-Dialog übergeben.
---
## Policies
Intune-Policies verwalten: Settings Catalog, Compliance, Konfigurationsprofile und Administrative Vorlagen.
- **Neu laden** — lädt alle vier Typen. Tabelle sortier-/durchsuchbar, Typ-Filter oben.
- **Spalte „Zuweisungen"** — zeigt die Anzahl der Ziele je Policy; Klick öffnet die Detail-Ansicht mit den konkreten Include-/Exclude-Gruppen bzw. „Alle Geräte / Alle Benutzer".
- **Export** — ausgewählte Policies als JSON (einzeln oder gebündelt); zusätzlich im Server-Archiv abgelegt.
- **Import** *(nur Read/Write)* — legt Policies **immer als neue Policy** an (nichts wird überschrieben). Nach dem Import kann optional pro Policy zugewiesen werden.
- **Konsolidieren** *(nur Read/Write, nur Settings Catalog)* — mehrere Settings-Catalog-Policies zu einer neuen zusammenführen; Konflikte werden pro Einstellung aufgelöst.
- **Gruppen zuweisen (Bulk)** *(nur Read/Write)* — siehe unten.
- **Git-Snapshot** — alle Policies als JSON in einen konfigurierten lokalen Git-Ordner schreiben und committen (versioniertes Backup).
### Gruppen zuweisen (Bulk)
Weist mehreren ausgewählten Policies **in einem Schritt** dieselben Gruppen zu — auch typübergreifend.
1. Eine oder mehrere Policies anhaken → Button **„Gruppen zuweisen"**.
2. Im Dialog **Include-/Exclude-Gruppen** wählen; zusätzlich die integrierten Ziele **Alle Geräte** und **Alle Benutzer**.
3. **Modus** wählen:
- **Hinzufügen** (Default) — bestehende Zuweisungen bleiben erhalten, die Auswahl kommt hinzu.
- **Ersetzen** — alle bisherigen Zuweisungen werden durch die Auswahl ersetzt.
4. **Zuweisen** — das Ergebnis wird pro Policy (Erfolg / übersprungen / Fehler) angezeigt.
> Hintergrund: Die Graph-`/assign`-Action ersetzt immer die komplette Zuweisungsliste. Der Hinzufügen-Modus liest deshalb die bestehenden Zuweisungen zuerst aus und sendet sie zusammen mit den neuen — ist das Auslesen nicht möglich, wird die Policy übersprungen statt versehentlich überschrieben.
---
## Offboarding
Geräte am Ende ihres Lebenszyklus sauber über alle Dienste entfernen.
- **Suche** — nach Gerätename oder Seriennummer. Durchsucht **Intune und Autopilot** — reine Autopilot-Geräte (registriert, aber nicht in Intune enrolled) werden also ebenfalls gefunden.
- Pro Treffer zeigen Badges, in welchen Diensten das Gerät vorhanden ist: **Intune**, **Entra**, **Autopilot** (und **Co-Mgmt**).
- **Recovery-Keys sichern** — vor dem Löschen lassen sich BitLocker-Keys und LAPS-Passwörter auslesen (`BitlockerKey.Read.All` / `DeviceLocalCredential.Read.All`).
- **Offboarden** — löscht pro Gerät wahlweise aus Intune, Autopilot und Entra (Entra: löschen oder nur deaktivieren). Nach dem Ausführen öffnet sich ein **HTML-Report** mit dem Ergebnis pro Gerät.
> **Wichtig:** Das Löschen benötigt bei delegierter Anmeldung zusätzlich passende Verzeichnis-/Intune-**Rollen** (z. B. Cloud Device Administrator / Intune Administrator) — die Graph-Scopes allein reichen nicht.
---
## Header
| Element | Funktion |
+160 -3
View File
@@ -41,6 +41,10 @@
</nav>
<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">
<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>
@@ -297,7 +301,7 @@
<!-- Tab-Leiste -->
<div class="gex-tab-bar group-mgmt-tabs">
<button class="gex-tab active" data-tab="export">Mitglieder &amp; Export</button>
<button class="gex-tab gex-add-pane" data-tab="add">Benutzer hinzufügen</button>
<button class="gex-tab gex-add-pane" data-tab="add">Benutzer | Gruppen hinzufügen</button>
<button class="gex-tab gex-add-pane" data-tab="import">CSV-Import</button>
<button class="gex-tab" data-tab="user">Benutzer</button>
</div>
@@ -386,7 +390,11 @@
<!-- Rechte Spalte: Benutzer hinzufügen -->
<div class="gex-right hidden" id="gexPaneAdd">
<div class="gex-section-title">Benutzer suchen</div>
<div class="gex-addmode">
<button type="button" class="gex-addmode-btn active" data-addmode="user">Benutzer</button>
<button type="button" class="gex-addmode-btn" data-addmode="group">Gruppen</button>
</div>
<div class="gex-section-title" id="gexAddSearchTitle">Benutzer suchen</div>
<div class="search-wrap" style="margin-bottom:10px;">
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="search" id="gexUserSearch" class="input input-search" placeholder="Name, UPN oder E-Mail…" autocomplete="off">
@@ -492,6 +500,21 @@
<option value="noncompliant">Non-Compliant</option>
<option value="unknown">Unbekannt</option>
</select>
<div id="devGroupWrap" style="position:relative;">
<input type="search" id="devGroupSearch" placeholder="Nach Gruppe filtern…" autocomplete="off"
style="height:34px;padding:0 24px 0 10px;border:1px solid var(--hairline);border-radius:6px;background:var(--canvas);color:var(--ink);font-size:12.5px;width:200px;box-sizing:border-box;">
<button id="devGroupClear" type="button" title="Gruppen-Filter entfernen" hidden
style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px;line-height:1;padding:0;">×</button>
<div id="devGroupResults" class="dev-group-results hidden"></div>
</div>
<button id="btnDevExport" class="btn btn-secondary btn-sm" disabled title="Angezeigte Geräte als CSV exportieren">
<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
</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 class="dev-layout">
@@ -500,9 +523,11 @@
<table class="dev-table" id="devTable">
<thead>
<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="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="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="lastSyncDateTime">Letzter Sync <span class="sort-ico"></span></th>
</tr>
@@ -594,6 +619,14 @@
<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
</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">
<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
</button>
<button class="btn btn-primary btn-sm" id="btnPolExport" disabled title="Ausgewählte Policies als JSON exportieren">
<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>
Export (<span id="polSelCount">0</span>)
@@ -607,11 +640,12 @@
<th class="sortable" data-col="name">Name <span class="sort-ico"></span></th>
<th class="sortable" data-col="typeLabel">Typ <span class="sort-ico"></span></th>
<th class="sortable" data-col="platform">Plattform <span class="sort-ico"></span></th>
<th class="sortable" data-col="assignmentCount">Zuweisungen <span class="sort-ico"></span></th>
<th class="sortable" data-col="lastModifiedDateTime">Geändert <span class="sort-ico"></span></th>
</tr>
</thead>
<tbody id="polBody">
<tr><td colspan="5" class="pol-hint">Klicke auf „Neu laden" um Policies zu laden.</td></tr>
<tr><td colspan="6" class="pol-hint">Klicke auf „Neu laden" um Policies zu laden.</td></tr>
</tbody>
</table>
</div>
@@ -900,6 +934,38 @@
</div>
</div>
<!-- MODAL: Policies konsolidieren (Settings Catalog) -->
<div id="modalPolConsolidate" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Policies zusammenführen</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 Einstellungen der ausgewählten <b>Settings-Catalog</b>-Policies werden zu
<b>einer neuen</b> Policy vereint (Neuanlage — die Originale bleiben unverändert).
</div>
<div id="polConsSummary" class="pol-cons-summary"></div>
<div id="polConsConflicts"></div>
<div class="form-group" style="margin-top:12px;">
<label class="lbl" for="polConsName">Name der neuen Policy</label>
<input type="text" id="polConsName" class="input" placeholder="z. B. Konsolidiert – Defender macOS">
</div>
<div class="form-group">
<label class="lbl" for="polConsDesc">Beschreibung (optional)</label>
<input type="text" id="polConsDesc" class="input">
</div>
</div>
<div class="modal-foot">
<span id="polConsInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="polConsConfirm" disabled>Zusammenführen</button>
</div>
</div>
</div>
<!-- MODAL: Policy-Zuweisung nach Import -->
<div id="modalPolAssign" class="modal hidden">
<div class="modal-backdrop" data-close></div>
@@ -923,6 +989,94 @@
</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>
<!-- Zuweisungen einer Policy ansehen -->
<div id="modalPolAssignView" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box">
<div class="modal-head">
<h3>Zuweisungen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div id="polAssignViewName" class="form-hint" style="margin-bottom:12px;"></div>
<div id="polAssignViewBody"></div>
</div>
<div class="modal-foot">
<button class="btn btn-secondary" data-close>Schließen</button>
</div>
</div>
</div>
<!-- Ergebnis der Bulk-Zuweisung -->
<div id="modalPolBulkResult" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Ergebnis der Zuweisung</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div id="polBulkResultSummary" class="form-hint" style="margin-bottom:12px;"></div>
<div id="polBulkResultBody" class="pol-result-list"></div>
</div>
<div class="modal-foot">
<button class="btn btn-primary" data-close>Schließen</button>
</div>
</div>
</div>
<!-- MODAL: Geräte-Offboarding -->
<div id="modalOffboard" class="modal hidden">
<div class="modal-backdrop" data-close></div>
@@ -1014,6 +1168,9 @@
.off-result { font-size:13px; padding:3px 0; overflow-wrap:anywhere; }
.off-result.ok { color:var(--success); }
.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; }
</style>
+145
View File
@@ -335,6 +335,24 @@ h1, h2, h3 {
.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 {
appearance: none;
@@ -1129,6 +1147,21 @@ a.target-link:hover {
}
.target-row:hover .entra-link,
.ex-member-row:hover .entra-link { opacity: 1; }
/* Gruppen-Filter-Dropdown im Geraete-Tab (nutzt .cg-existing-item fuer die Zeilen) */
.dev-group-results {
position: absolute;
z-index: 40;
top: 38px;
left: 0;
min-width: 220px;
max-height: 260px;
overflow-y: auto;
background: var(--canvas);
border: 1px solid var(--hairline);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0,0,0,0.28);
}
.entra-link:hover {
background: var(--surface-soft);
color: var(--brand-accent);
@@ -1327,6 +1360,7 @@ html[data-theme="dark"] .entra-link.teams-link:hover {
border-radius: var(--r-md);
}
.member-row {
display: flex;
align-items: center;
@@ -4615,6 +4649,25 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown {
flex-shrink: 0;
}
/* Umschalter Benutzer/Gruppen im "Hinzufuegen"-Pane */
.gex-addmode { display: flex; gap: 6px; margin-bottom: 12px; }
.gex-addmode-btn {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--hairline);
border-radius: var(--r-sm, 6px);
background: var(--canvas);
color: var(--muted);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.gex-addmode-btn.active {
background: var(--accent-soft);
color: var(--brand-accent);
border-color: var(--brand-accent);
}
.gex-tab {
padding: 10px 18px;
font-size: 13px;
@@ -4934,6 +4987,7 @@ body.read-only .gex-add-pane,
body.read-only .gex-tab[data-tab="add"],
body.read-only .gex-tab[data-tab="import"],
body.read-only #gexPaneImport,
body.read-only .dev-offboard-btn,
body.read-only .pol-write {
display: none !important;
}
@@ -5338,6 +5392,8 @@ body.read-only .app-row {
}
.dev-table thead th.sortable { cursor: pointer; }
.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 {
cursor: pointer;
border-bottom: 1px solid var(--hairline-soft);
@@ -5371,6 +5427,8 @@ body.read-only .app-row {
.dev-badge-ok { background: rgba(74,222,128,.15); color: #4ade80; }
.dev-badge-err { background: rgba(248,113,113,.15); color: #f87171; }
.dev-badge-unk { background: var(--hairline-soft); color: var(--muted); }
.dev-badge-intune { background: rgba(96,165,250,.15); color: #60a5fa; }
.dev-badge-comgmt { background: rgba(251,191,36,.15); color: #fbbf24; }
/* Detail-Panel */
.dev-detail {
@@ -5428,6 +5486,31 @@ body.read-only .app-row {
border: 1px solid var(--border);
border-radius: 6px;
}
/* Policies konsolidieren — Modal */
.pol-cons-summary { font-size: 13px; line-height: 1.6; }
.pol-cons-summary .pol-cons-warn { color: var(--pink, #e0567a); }
.pol-cons-err { color: var(--pink, #e0567a); font-size: 13px; }
.pol-cons-src { margin: 6px 0 0; padding-left: 18px; font-size: 12px; }
.pol-cons-src li { margin: 1px 0; }
.pol-cons-conf-head { margin: 14px 0 6px; font-weight: 600; font-size: 13px; }
.pol-cons-conf {
border: 1px solid var(--hairline-soft, var(--border));
border-radius: var(--r-xs, 6px);
padding: 8px 10px;
margin-bottom: 6px;
}
.pol-cons-def {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 6px;
}
.pol-cons-opts { display: flex; flex-wrap: wrap; gap: 6px 16px; }
.pol-cons-opt { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; }
.pol-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.pol-table thead { position: sticky; top: 0; z-index: 2; background: var(--bg2); }
.pol-table th {
@@ -5469,6 +5552,68 @@ body.read-only .app-row {
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); }
.pol-assign-builtin { margin-top: 10px; }
/* 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; }
/* Zuweisungs-Spalte + Detailansicht */
.pol-asg-badge {
min-width: 24px; padding: 1px 8px; border: 1px solid var(--hairline, #333);
border-radius: var(--r-pill, 999px); background: var(--bg3, var(--hover));
color: var(--ink, inherit); font-size: 12px; font-weight: 600; cursor: pointer;
}
.pol-asg-badge:hover { border-color: var(--accent, #60a5fa); color: var(--accent, #60a5fa); }
.pol-av-group .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
/* Ergebnis-Report der Zuweisung */
.pol-result-list { max-height: 50vh; overflow-y: auto; }
.pol-res-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--hairline-soft, rgba(255,255,255,.06)); font-size: 13px; }
.pol-res-row:last-child { border-bottom: 0; }
.pol-res-name { font-weight: 500; }
.pol-res-msg { color: #f87171; font-size: 12px; margin-left: auto; text-align: right; }
.pol-res-badge { flex: none; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-pill, 999px); }
.pol-res-badge.ok { background: rgba(74,222,128,.15); color: #4ade80; }
.pol-res-badge.err { background: rgba(248,113,113,.15); color: #f87171; }
.pol-res-badge.skip { background: var(--hairline-soft); color: var(--muted, #999); }
.pol-res-ok { color: #4ade80; }
.pol-res-err { color: #f87171; }
/* =============================================================
Multi-Tenant: Topbar-Umschalter + Profil-Editor
============================================================= */