18 Commits
Author SHA1 Message Date
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
marcoandClaude Opus 4.8 446c53f409 v0.1.30 — Policy-Bulk-Import gehaertet, macOS-Apps, Gruppen-Suche & Direkt-Zuweisung
Build & Release MSI / build-msi (push) Canceled after 0s
- Bulk-Import von Policies (Batches + Fortschritt, Fehler-Isolierung)
- Export/Import-Treue via Raw-JSON (-OutputType Json), null-Collections-Strip,
  Settings-Catalog-Reparatur + Korruptions-Erkennung, Git-Resolver
- macOS-Apps in Liste/Suche + Plattform-Filter
- "Gruppe"-Tab durchsucht alle Gruppen (Live-Suche) statt nur Praefix-Gruppen
- Bestehende Gruppe(n) direkt an Apps zuweisen inkl. Mehrfachauswahl
- Fix: Add-GraphAppAssignment merged bestehende Zuweisungen (/assign ersetzt sonst
  die komplette Liste) -> kein Ueberschreiben mehr, ein Request fuer alle Ziele

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 09:03:22 +02:00
marcoandClaude Opus 4.8 5c60eacc12 v0.1.29 — Geraete-Offboarding (v1)
Build & Release MSI / build-msi (push) Canceled after 0s
Neu:
- Geraete-Offboarding: Geraete ueber Intune, Autopilot und Entra ID
  entfernen. Suche + ID-Aufloesung (via $batch), Recovery-Keys
  (BitLocker/FileVault/LAPS) vor dem Loeschen, Bestaetigungs-Dialog mit
  Dienst-Auswahl + 403/Multi-Admin-Approval-Handling, im Read-Only gesperrt.
  Neues Modul src/Offboard.ps1, Endpoints /api/offboard/{search,keys,execute}.
  Portiert aus Device Offboarding Manager (Ugur Koc, MIT).
- Offboarding-Berechtigungen dokumentiert; Setup-Skript -IncludeOffboarding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 12:37:41 +02:00
marcoandClaude Opus 4.8 830dddd15a v0.1.28 — Umbenennen beim Import, UI-Fixes, Geraete-Export repariert
Build & Release MSI / build-msi (push) Canceled after 0s
Neu:
- Umbenennen beim Import: Policy-Name vor dem Anlegen anpassbar
  (Dialog "Policies importieren"), richtiges Namensfeld je Typ

Behoben:
- Geraete-Export im App Report: entfernter sync-Endpoint
  getDeviceInstallStatusReport (400) -> async Export-Job
  DeviceInstallStatusByApp; ZIP/CSV-Logik in Get-IntuneReportRows
- Darkmode-Kontrast im Zuweisungs-Dialog (Theme-Variablen statt fester Farben)
- Dialog-Breite: lange Policy-Namen brechen um statt H-Scroll

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 09:23:59 +02:00
marcoandClaude Opus 4.8 c1fc6f8b94 v0.1.27 — App-Registrierungs-Doku + Setup-Skript, Version-Bump
Build & Release MSI / build-msi (push) Canceled after 0s
- docs/App-Registration.md: alle benoetigten Graph-Berechtigungen
  (Kern, Geraete-Tab, Read-Only) + Auth-Konfiguration
- docs/Setup-AppRegistration.ps1: legt die App-Registrierung automatisch an
  (Rechte, Public-Client-Flow, Redirect-URIs, optional Admin-Consent);
  Berechtigungs-IDs werden live aufgeloest
- README/CHANGELOG verlinkt, ToolVersion + build-local auf 0.1.27

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:36:42 +02:00
marcoandClaude Opus 4.8 4efc71376b Policy-Import haerten, Admin-Vorlagen, Zuweisung nach Import, Git-Snapshot
Neu:
- Administrative Vorlagen (ADMX / groupPolicyConfigurations) als vierter
  Policy-Typ fuer Export/Import (GET /api/policies/administrativetemplate)
- Zuweisung direkt nach Import: pro Policy Include-/Exclude-Gruppen
  (POST /api/policies/assign, typ-spezifische /assign-Action)
- Git-Snapshot: versioniertes Policy-Backup in lokalen Git-Ordner
  (Settings-Sektion policyBackup)
- Import akzeptiert Git-Snapshot-Dateien (policyType neben exportType)

Behoben:
- Settings-Catalog-Import schema-konform: Collection-Properties immer als
  Array (repariert PS-5.1-Roundtrip), null -> [], Wrapper-@odata.type,
  read-only id entfernt
- Import-Phantom-Fehler (@($null)-Geisterdurchlauf im Archiv-Pfad)
- Frontend-Cache-Reset beim Tenant-Wechsel (resetClientState)
- WAM-Anmeldefenster zuverlaessig im Vordergrund (AttachThreadInput)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:25:19 +02:00
marcoandClaude Opus 4.8 450428e7f7 CI: Token-Laengen-Diagnose im Gitea-Upload-Schritt entfernt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 00:49:46 +02:00
15 changed files with 4380 additions and 249 deletions
+1 -3
View File
@@ -119,10 +119,8 @@ jobs:
MSI_NAME: ${{ steps.build.outputs.MSI_NAME }} MSI_NAME: ${{ steps.build.outputs.MSI_NAME }}
COMMIT_SHA: ${{ github.sha }} COMMIT_SHA: ${{ github.sha }}
run: | run: |
$tokLen = if ([string]::IsNullOrEmpty($env:GITEA_TOKEN)) { 0 } else { $env:GITEA_TOKEN.Length }
Write-Host "Diagnose: GITEA_TOKEN Laenge = $tokLen (0 = Secret erreicht den Lauf nicht)"
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
Write-Host "::warning::GITEA_TOKEN leer (Laenge=$tokLen) — Gitea-Upload uebersprungen. Muss ein REPOSITORY-Secret sein unter Settings > Secrets and variables > Actions (Tab 'Secrets', NICHT 'Variables'), Name exakt GITEA_TOKEN, im Repo mwende/Intune-Manager." Write-Host "::notice::GITEA_TOKEN nicht gesetzt — Gitea-Upload uebersprungen."
exit 0 exit 0
} }
$headers = @{ Authorization = "token $env:GITEA_TOKEN" } $headers = @{ Authorization = "token $env:GITEA_TOKEN" }
+140
View File
@@ -5,6 +5,146 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan
--- ---
## [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.
### Neu
- **Bulk-Import von Policies**: Mehrere Policy-Dateien (oder eine Bundle-Datei) auf einmal importieren. Läuft server-seitig in Batches à 5 mit Fortschrittsanzeige und Fehler-Isolierung pro Batch — umgeht das 180-s-Timeout einzelner Requests.
- **macOS-Apps** erscheinen jetzt in App-Liste und -Suche (DMG, PKG, LOB, Defender, Edge, M365, VPP) mit eigenen Typ-Labels. Neuer **Plattform-Filter** (Alle / Windows / macOS).
- **Gruppen-Suche** im Ziel-Panel: Der frühere „Abteilung"-Tab heißt jetzt **„Gruppe"** und durchsucht per Live-Suche **alle** Tenant-Gruppen (statt nur Präfix-Gruppen). Auswahl bleibt über mehrere Suchen erhalten.
- **Bestehende Gruppe(n) direkt einer App zuweisen**: Im Dialog „Zuweisung hinzufügen" neue Option „Bestehende Gruppe" (Required/Available) mit Gruppensuche und **Mehrfachauswahl** (Chips).
### Behoben
- **App-Zuweisung überschrieb bestehende Zuweisungen**: `Add-GraphAppAssignment` nutzte die `/assign`-Action mit nur einer Zuweisung — diese ersetzt aber die komplette Liste. Jetzt werden bestehende Zuweisungen geladen, neue gemergt (Duplikate übersprungen) und in **einem** Request geschickt. Behebt sowohl die Mehrfach-Zuweisung als auch stillen Verlust vorhandener Zuweisungen beim Einzel-Hinzufügen.
- **Policy-Export/-Import-Treue** (Windows PowerShell 5.1): Policy-Details werden als **rohes JSON** von Graph geholt (`-OutputType Json`), sonst skalarisierte der Hashtable-Modus Ein-Element-Collections (z. B. `printerNames`) → 400 beim Re-Import.
- **Configuration-Profile-Import**: `null`-Werte für nicht-nullbare Collections werden entfernt (400 `ModelValidationFailure`).
- **Settings-Catalog-Import**: Reparatur leerer/verstümmelter Collections (`[""]`, `{}`), Rettung stringifizierter `*TemplateReference`-Felder und **klarer Abbruch** bei irreparabel beschädigten Quelldateien (alte Exporte mit zu geringer JSON-Tiefe).
- **Git-Snapshot**: `git` wird jetzt auch über bekannte Installationsorte gefunden, falls es nicht im (veralteten) PATH des Server-Prozesses liegt.
---
## [0.1.29] - 2026-08-24
Geräte-Offboarding (v1).
### Neu
- **Geräte-Offboarding** als neue Ansicht: Geräte über **Intune, Autopilot und Entra ID** hinweg entfernen (Decommissioning). Portiert aus dem [Device Offboarding Manager](https://github.com/ugurkocde/DeviceOffboardingManager) von Ugur Koc (MIT-Lizenz).
- Suche per Gerätename/Seriennummer, Auflösung der IDs über alle drei Dienste (`$batch`)
- **Recovery-Keys vor dem Löschen**: BitLocker/FileVault und LAPS-Passwörter werden angezeigt
- Bestätigungs-Dialog mit Dienst-Auswahl (Entra löschen/deaktivieren, Intune, Autopilot), Pflicht-Bestätigung und Ergebnis-Liste; **403/Multi-Admin-Approval** wird abgefangen
- Im **Read-Only-Modus gesperrt**
- Neue Endpoints: `GET /api/offboard/search`, `POST /api/offboard/keys`, `POST /api/offboard/execute` (neues Modul `src/Offboard.ps1`)
- Zusätzliche Berechtigungen dokumentiert; Setup-Skript-Schalter `-IncludeOffboarding`. (MDE-Offboarding bewusst nicht in v1.)
---
## [0.1.28] - 2026-08-24
Umbenennen beim Import, UI-Fixes, Geräte-Export repariert.
### Neu
- **Umbenennen beim Import**: Vor dem Anlegen kann jeder importierten Policy im neuen Dialog „Policies importieren" ein anderer Name gegeben werden (das richtige Namensfeld je Typ — `name`/`displayName` — wird gesetzt).
### Behoben
- **Geräte-Export im App Report**: Der von Graph entfernte synchrone Endpoint `getDeviceInstallStatusReport` (400 „Resource not found for the segment") ist durch den asynchronen Export-Job **`DeviceInstallStatusByApp`** ersetzt (ZIP/CSV-Auswertung, tolerantes Spalten-Mapping). ZIP/CSV-Logik in `Get-IntuneReportRows` ausgelagert.
- **Darkmode-Kontrast** im Zuweisungs-Dialog: Gruppen-Chips nutzen jetzt die Theme-Variablen (`--surface-strong`/`--ink` statt fester heller Farben) — heller Text auf hellem Chip behoben.
- **Dialog-Breite**: Lange Policy-Namen im Zuweisungs-/Import-Dialog brechen jetzt um (`overflow-wrap`/`flex-wrap`) statt horizontal zu scrollen; das Fenster passt sich der Breite an.
---
## [0.1.27] - 2026-08-23
Policy-Import härter, Administrative Vorlagen, Zuweisung nach Import, Git-Snapshot, App-Registrierungs-Doku.
### Neu
- **Administrative Vorlagen (ADMX / `groupPolicyConfigurations`)** als vierter Policy-Typ für Export und Import. Beim Import wird die Konfigurations-Hülle angelegt und jede Einstellung einzeln über `definition@odata.bind` / `presentation@odata.bind` angehängt (eingebaute ADMX-Definitionen sind tenantübergreifend gültig). Neuer Endpoint `GET /api/policies/administrativetemplate`.
- **Zuweisung direkt nach Import**: Dialog listet die neu angelegten Policies und lässt **pro Policy Include-/Exclude-Gruppen** im Ziel-Tenant zuweisen (Typeahead über die vorhandene Gruppensuche). Neuer Endpoint `POST /api/policies/assign` (nutzt die typ-spezifische `/assign`-Action für alle vier Typen).
- **Git-Snapshot (versioniertes Policy-Backup)**: Button „Git-Snapshot" schreibt alle Policies aller Typen als JSON in einen konfigurierten lokalen Git-Ordner und committet sie (optional `git push` über den vorhandenen Credential-Helper). Stabile Dateinamen + deterministische, zeitstempel-freie Ausgabe → saubere Diffs. Neue Settings-Sektion `policyBackup` (`gitRepoPath`, `push`).
- Import akzeptiert jetzt auch **Git-Snapshot-Dateien** (Feld `policyType` zusätzlich zu `exportType`).
- **Doku + Provisioning-Skript für die App-Registrierung**: [`docs/App-Registration.md`](docs/App-Registration.md) listet alle benötigten delegierten Graph-Berechtigungen (Kern, Geräte-Tab, Read-Only) samt Auth-Konfiguration; [`docs/Setup-AppRegistration.ps1`](docs/Setup-AppRegistration.ps1) legt die Registrierung automatisch an bzw. aktualisiert sie (Rechte, Public-Client-Flow, Redirect-URIs, optional Admin-Consent) — Berechtigungs-IDs werden live aufgelöst, keine fest verdrahteten GUIDs.
### Behoben
- **Settings-Catalog-Import** war gegen Graph-Schema-Fehler anfällig: verschachtelte Collection-Properties (`children`, `*SettingCollectionValue`) werden jetzt konsequent als Arrays serialisiert (repariert PowerShell-5.1-JSON-Roundtrip-Schäden), `null`-Collections werden zu `[]` (Graph verlangt `Nullable=False`), der Wrapper-`@odata.type` wird je Setting gesetzt und das read-only `id`-Feld entfernt.
- **Phantom-Fehler beim Import**: ein fehlendes `fileNames`-Feld erzeugte über `@($null)` einen Geisterdurchlauf im Archiv-Pfad und meldete fälschlich „1 fehlgeschlagen".
- **Tenant-Wechsel**: das Frontend zeigte weiter die Apps/Gruppen des vorherigen Tenants, weil die Lade-Guards nicht zurückgesetzt wurden. Alle datentragenden Caches werden jetzt beim Wechsel verworfen (`resetClientState`).
- **WAM-Anmeldefenster** kommt beim Login/Tenant-Wechsel zuverlässig in den Vordergrund (AttachThreadInput + kurzer ALT-Tap zum Lösen des Windows-Foreground-Locks).
---
## [0.1.26] - 2026-08-21 ## [0.1.26] - 2026-08-21
Multi-Tenant, Pro-Tenant-Vorgaben, RPA entfernt. Multi-Tenant, Pro-Tenant-Vorgaben, RPA entfernt.
+116 -20
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) - App-Zuweisung (Required / Available hinzufuegen und entfernen)
- Gruppen erstellen und loeschen - Gruppen erstellen und loeschen
- Tab „Benutzer hinzufuegen" im Group Management - Tab „Benutzer | Gruppen hinzufuegen" im Group Management
- Tab „CSV-Import" im Group Management - Tab „CSV-Import" im Group Management
- Session-Ausfuehren-Button - Session-Ausfuehren-Button
- Alle Rename-/Delete-Buttons - Alle Rename-/Delete-Buttons
@@ -105,20 +105,64 @@ Settings → Verbindung
Scopes RO: DeviceManagementApps.Read.All ... Scopes RO: DeviceManagementApps.Read.All ...
``` ```
### Benoetigt Graph-Berechtigungen ### Benoetigte Graph-Berechtigungen (voller Funktionsumfang)
| Funktion | Benoetigt | Alle Berechtigungen sind **delegiert** (Microsoft Graph) — die App handelt im Namen
|----------------------------------|----------------------------------------------| des angemeldeten Admins. Der **Kern** deckt App-Management, Group-Management und
| Apps anzeigen | `DeviceManagementApps.Read.All` | Policies ab; die **Geräte-** und **Offboarding**-Scopes sind optional und nur für
| Gruppen anzeigen / Mitglieder | `GroupMember.Read.All` | den jeweiligen Tab nötig. `offline_access` liefert das Refresh-Token (Device-Code-Flow).
| User suchen | `User.Read.All` |
| Zuweisungen aendern | `DeviceManagementApps.ReadWrite.All` | **Kern — immer erforderlich (App Management, Group Management, App Report, Policies)**
| Gruppen-Mitglieder aendern | `GroupMember.ReadWrite.All` |
| App-Installationszaehler (Report)| `DeviceManagementManagedDevices.Read.All` ⚠ | | Berechtigung | Wofür |
| Geraete-Exportliste pro App | `DeviceManagementManagedDevices.Read.All` ⚠ | |---|---|
| `Group.ReadWrite.All` | Gruppen anlegen, App-/Policy-Zuweisungen setzen |
| `GroupMember.ReadWrite.All` | Gruppenmitglieder lesen/hinzufügen/entfernen, CSV-Import |
| `User.Read.All` | Benutzersuche, UPN-Auflösung |
| `DeviceManagementApps.ReadWrite.All` | Apps auflisten, zuweisen, umbenennen, löschen, Setup-Datei aktualisieren |
| `DeviceManagementConfiguration.ReadWrite.All` | Policies (Settings Catalog, Compliance, Konfigurationsprofile, Administrative Vorlagen) lesen, exportieren, importieren, **zuweisen (Bulk)**, konsolidieren, Git-Snapshot |
| `DeviceManagementManagedDevices.Read.All` | App-Report (Installationszähler) und Geräte-Export pro App ⚠ |
| `offline_access` | Refresh-Token |
> ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App-Report nur > ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App-Report nur
> Zaehler = 0 und der Geraete-Export liefert keine Eintraege. > Zähler = 0 und der Geräte-Export liefert keine Einträge. Dieser Scope ist
> zugleich die Basis für den gesamten Geräte-Tab (siehe unten).
**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).
--- ---
@@ -127,10 +171,10 @@ Settings → Verbindung
| Tab | Beschreibung | | Tab | Beschreibung |
|--------------------|-------------------------------------------------------------| |--------------------|-------------------------------------------------------------|
| App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen | | 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 | | App Report | Installationszaehler pro App, Geraete-Export als CSV |
| Geräte | Geraete suchen, Detail-Panel, Aktionen (Sync, Wipe, …) | | Geräte | Geraete suchen, Filter (auch nach **Gruppe**), Spalte **Verwaltung** (Intune / Co-Managed), CSV-Export, **Bulk-Offboarding**, Detail-Panel (inkl. **Autopilot**-Status) und Aktionen (Sync, Wipe, …) |
| Policies | Policies exportieren (JSON-Download) und importieren (Neuanlage) | | Policies | Policies exportieren/importieren (Neuanlage), **zusammenfuehren** (Settings Catalog) und **Gruppen zuweisen** (Bulk) |
--- ---
@@ -143,10 +187,11 @@ Settings → Verbindung
- Filter zum Einschraenken der Anzeige - Filter zum Einschraenken der Anzeige
- **Als CSV exportieren** laedt alle Mitglieder als `members-<Gruppe>-<Datum>.csv` - **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 - Umschalter **Benutzer / Gruppen** oben im Tab
- Mehrere Benutzer auswaehlen und gemeinsam hinzufuegen - 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)* ### CSV-Import *(nur Read/Write)*
@@ -230,6 +275,39 @@ neu an. Unterstuetzte Typen:
> bekommen bei Bedarf einen Default-`scheduledActionsForRule`-Block, den Graph > bekommen bei Bedarf einen Default-`scheduledActionsForRule`-Block, den Graph
> beim Anlegen zwingend verlangt. > 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 ### Benoetigt Graph-Berechtigung
`DeviceManagementConfiguration.ReadWrite.All` (Import) bzw. `DeviceManagementConfiguration.ReadWrite.All` (Import) bzw.
@@ -237,6 +315,21 @@ neu an. Unterstuetzte Typen:
automatisch zur Verbindung ergaenzt — in der Azure-App-Registration muss die automatisch zur Verbindung ergaenzt — in der Azure-App-Registration muss die
Berechtigung aber vorhanden und (Admin-)zugestimmt sein. Berechtigung aber vorhanden und (Admin-)zugestimmt sein.
### Git-Snapshot (versioniertes Backup)
Button **„Git-Snapshot"** im Policies-Tab schreibt **alle** Policies aller Typen
als JSON in einen konfigurierten **lokalen Git-Ordner** und committet sie
automatisch — ideal als versioniertes Backup mit nachvollziehbarer History.
- Konfiguration: **Settings → Policy-Backup (Git)** — lokaler Repo-Pfad (wird bei
Bedarf angelegt und `git init`-isiert) + optional **automatischer `git push`**
(nutzt den vorhandenen Git-Credential-Helper; **kein Token in der App**).
- **Stabile Dateinamen** (`<tenant>/<typ>/<name>__<id8>.json`) und deterministische,
zeitstempel-freie JSON-Ausgabe → saubere Git-Diffs zwischen Snapshots. Entfernte
Policies verschwinden aus dem Snapshot.
- Multi-Tenant: je Mandant ein eigener Unterordner (nach Profil-Label bzw. Tenant-ID).
- Gibt es keine Aenderungen seit dem letzten Snapshot, wird nichts committet.
--- ---
## Architektur ## Architektur
@@ -275,8 +368,9 @@ Intune Manager/
| POST | `/api/groups/resolve-upns` | Read | UPN-Liste → UserId + DisplayName | | POST | `/api/groups/resolve-upns` | Read | UPN-Liste → UserId + DisplayName |
| GET | `/api/groups/{id}/members` | Read | Mitglieder einer Gruppe | | GET | `/api/groups/{id}/members` | Read | Mitglieder einer Gruppe |
| GET | `/api/groups/{id}/members/export` | Read | Mitglieder als CSV | | GET | `/api/groups/{id}/members/export` | Read | Mitglieder als CSV |
| POST | `/api/groups/{id}/members` | **Write** | Benutzer zur Gruppe hinzufuegen | | POST | `/api/groups/{id}/members` | **Write** | Benutzer/Gruppen zur Gruppe hinzufuegen |
| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer aus Gruppe entfernen | | 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/users?q=` | Read | Benutzer suchen |
| GET | `/api/apps` | Read | Apps laden (cached) | | GET | `/api/apps` | Read | Apps laden (cached) |
| POST | `/api/apps/refresh` | Read | Apps neu laden | | POST | `/api/apps/refresh` | Read | Apps neu laden |
@@ -291,6 +385,8 @@ Intune Manager/
| POST | `/api/policies/export` | Read+Cfg | Ausgewaehlte Policies als JSON exportieren | | POST | `/api/policies/export` | Read+Cfg | Ausgewaehlte Policies als JSON exportieren |
| GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten | | GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten |
| POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren | | POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren |
| POST | `/api/policies/consolidate` | **Write** | Settings-Catalog-Policies zusammenfuehren |
| POST | `/api/policies/assign` | **Write** | Policies Gruppen/Alle-Geraete/-Benutzer zuweisen (Bulk, Add/Replace) |
> **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All` > **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All`
> **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import) > **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import)
+2 -1
View File
@@ -137,6 +137,7 @@ if (-not $SkipModuleCheck) {
. (Join-Path $root "src/Graph.ps1") . (Join-Path $root "src/Graph.ps1")
. (Join-Path $root "src/Api.ps1") . (Join-Path $root "src/Api.ps1")
. (Join-Path $root "src/PolicyIO.ps1") . (Join-Path $root "src/PolicyIO.ps1")
. (Join-Path $root "src/Offboard.ps1")
. (Join-Path $root "src/Router.ps1") . (Join-Path $root "src/Router.ps1")
. (Join-Path $root "src/Server.ps1") . (Join-Path $root "src/Server.ps1")
@@ -188,7 +189,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen Session = @() # geplante Zuweisungen
} }
$script:ToolVersion = "0.1.26" $script:ToolVersion = "0.1.35"
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host "" Write-Host ""
+151
View File
@@ -0,0 +1,151 @@
# App-Registrierung & Berechtigungen
Der Intune Manager meldet sich als **delegierte** Anwendung an (Device-Code- bzw.
WAM-Login) — er handelt also immer **im Namen des angemeldeten Admins**, nie mit
Anwendungsrechten. Die App-Registrierung braucht daher **delegierte
Microsoft-Graph-Berechtigungen** plus die passende Authentifizierungs-Konfiguration.
> **Schnellweg:** Statt alles von Hand zu klicken, legt das Skript
> [`Setup-AppRegistration.ps1`](Setup-AppRegistration.ps1) die Registrierung
> komplett an (Rechte + Public-Client-Flow + Redirect-URIs + optional Consent).
> Siehe [Automatische Einrichtung](#automatische-einrichtung).
---
## Benötigte Berechtigungen
Alle Berechtigungen sind vom Typ **Delegiert** (Microsoft Graph).
### Kern (immer erforderlich)
| 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, App-Report |
| `DeviceManagementConfiguration.ReadWrite.All` | Policies (Settings Catalog, Compliance, Konfigurationsprofile, Administrative Vorlagen) lesen, exportieren, importieren, zuweisen |
| `offline_access` | Refresh-Token (Device-Code-Flow) |
> `DeviceManagementConfiguration.ReadWrite.All` wird vom Tool automatisch zur
> Verbindung ergänzt — die App-Registrierung muss die Berechtigung aber besitzen
> und (Admin-)zugestimmt bekommen haben, sonst schlägt der Policy-Import mit
> `403 Forbidden` fehl.
### Geräte-Tab (optional)
Nur nötig, wenn der **Geräte-Tab** (Sync/Reboot/Lock/Diagnose/Wipe/Retire) genutzt wird:
| Berechtigung | Wofür |
|---|---|
| `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 |
### Geräte-Offboarding (optional)
Nur nötig, wenn die **Offboarding**-Ansicht (Geräte aus Intune + Autopilot + Entra
entfernen, Recovery-Keys lesen) genutzt wird:
| Berechtigung | Wofür |
|---|---|
| `Device.ReadWrite.All` | Gerät aus Entra ID löschen/deaktivieren |
| `DeviceManagementManagedDevices.ReadWrite.All` | Gerät aus Intune 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 |
> **Wichtig:** Löschen aus Entra/Intune/Autopilot benötigt bei delegierter
> Anmeldung zusätzlich **Verzeichnis-/Intune-Rollen** (Cloud Device Administrator
> bzw. Intune Administrator) — die Scopes allein reichen nicht, sonst `403`.
> Diese Scopes müssen außerdem in den **Read/Write-Scopes** des Tools eingetragen
> sein (Einstellungen → Verbindung), damit sie im Token landen.
> Skript: `.\docs\Setup-AppRegistration.ps1 -IncludeOffboarding -GrantAdminConsent`
### Read-Only-Variante
Für eine reine Anzeige-App (im Tool als `clientIdRo` hinterlegbar) genügen 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`).
---
## Authentifizierungs-Konfiguration
Zusätzlich zu den Berechtigungen muss die App als **öffentlicher Client** nutzbar
sein — sonst scheitert der erste Login (`AADSTS500113` bzw. `AADSTS50011`):
1. **Öffentliche Clientflows zulassen** → **Ja**
(Entra → App-Registrierung → *Authentifizierung* → ganz unten;
im Manifest `isFallbackPublicClient = true`).
2. **Redirect-URIs** unter *Mobilgerät- und Desktopanwendungen*:
```
https://login.microsoftonline.com/common/oauth2/nativeclient
ms-appx-web://Microsoft.AAD.BrokerPlugin/<CLIENT-ID>
```
- Zeile 1 → **Device-Code-Flow**
- Zeile 2 → **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*
(`AzureADMultipleOrgs`) stehen und im Ziel-Tenant per Admin-Consent
bereitgestellt werden. Bei einer eigenen App **pro** Tenant ist Single-Tenant
ausreichend.
---
## Automatische Einrichtung
Das Skript [`Setup-AppRegistration.ps1`](Setup-AppRegistration.ps1) erledigt alles
oben Genannte über Microsoft Graph. Es braucht nur das Modul
`Microsoft.Graph.Authentication` (dieselbe Abhängigkeit wie das Tool) und löst die
Berechtigungs-IDs **live** aus dem Graph-Service-Principal auf — keine fest
verdrahteten GUIDs.
```powershell
# Einmalig:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
# Neue Single-Tenant-App inkl. Admin-Consent:
.\docs\Setup-AppRegistration.ps1 -GrantAdminConsent
# Mit Geräte-Rechten:
.\docs\Setup-AppRegistration.ps1 -IncludeDeviceActions -GrantAdminConsent
# Multi-Tenant-App:
.\docs\Setup-AppRegistration.ps1 -MultiTenant -GrantAdminConsent
# Bestehende App nur um Rechte/Redirect-URIs ergänzen:
.\docs\Setup-AppRegistration.ps1 -ClientId "<APP-ID>" -GrantAdminConsent
# Nur das Manifest-Snippet ausgeben (ohne etwas anzulegen wäre -WhatIf-artig —
# hier zusätzlich zum Anlegen):
.\docs\Setup-AppRegistration.ps1 -EmitManifest
```
Am Ende gibt das Skript **Tenant ID** und **Client ID** aus — diese Werte im
Intune Manager unter *Einstellungen → Verbindung* (bzw. im Tenant-Profil)
eintragen.
Die zum Anlegen/Ändern nötigen Admin-Rechte (`Application.ReadWrite.All`, für
Consent zusätzlich `DelegatedPermissionGrant.ReadWrite.All`) werden beim
`Connect-MgGraph`-Aufruf des Skripts einmalig abgefragt.
---
## Manuelle Einrichtung (Portal)
1. **Entra** → *App-Registrierungen* → *Neue Registrierung* → Name vergeben,
Kontotyp wählen (Single- oder Multi-Tenant) → **Registrieren**.
2. *API-Berechtigungen* → *Berechtigung hinzufügen* → **Microsoft Graph** →
**Delegierte Berechtigungen** → die [Kern-Berechtigungen](#kern-immer-erforderlich)
(und bei Bedarf die [Geräte-Berechtigungen](#geräte-tab-optional)) auswählen →
**Administratorzustimmung erteilen**.
3. *Authentifizierung* → [Authentifizierungs-Konfiguration](#authentifizierungs-konfiguration)
wie oben setzen (Public-Client-Flow + beide Redirect-URIs).
4. **Übersichtsseite**: *Anwendungs-(Client-)ID* und *Verzeichnis-(Mandanten-)ID*
in den Intune Manager übernehmen.
+227
View File
@@ -0,0 +1,227 @@
<#
.SYNOPSIS
Legt die App-Registrierung fuer den Intune Manager an (oder aktualisiert sie)
inkl. Graph-Berechtigungen, Public-Client-Flow und Redirect-URIs.
.DESCRIPTION
Deckt genau die Punkte ab, an denen der erste Login sonst mit
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
* optional Admin-Consent
Nutzt nur Microsoft.Graph.Authentication (Invoke-MgGraphRequest) — dieselbe
Abhaengigkeit wie das Tool selbst. Berechtigungs-IDs werden LIVE aus dem
Graph-Service-Principal aufgeloest, es sind also keine fest verdrahteten
GUIDs noetig (die sonst leicht veralten).
.PARAMETER DisplayName
Anzeigename der App-Registrierung. Default: "Intune Manager".
.PARAMETER ClientId
AppId einer BESTEHENDEN Registrierung, die aktualisiert werden soll.
Ohne diesen Parameter wird eine NEUE App angelegt.
.PARAMETER MultiTenant
App fuer mehrere Tenants (signInAudience = AzureADMultipleOrgs).
Default: nur der aktuelle Tenant (AzureADMyOrg).
.PARAMETER ReadOnly
Verwendet die Read-Only-Berechtigungen (fuer eine reine Anzeige-/RO-App,
passend zu clientIdRo im Tool).
.PARAMETER IncludeDeviceActions
Nimmt zusaetzlich die Berechtigungen fuer den Geraete-Tab auf
(Read + ReadWrite + PrivilegedOperations = Sync/Reboot/Lock/Wipe/Retire).
.PARAMETER GrantAdminConsent
Erteilt direkt tenantweiten Admin-Consent fuer die gesetzten Scopes.
Benoetigt entsprechend privilegierte Anmeldung.
.PARAMETER EmitManifest
Gibt zusaetzlich den requiredResourceAccess-Block als JSON aus (zum manuellen
Einfuegen in das App-Manifest im Portal).
.EXAMPLE
# Neue Single-Tenant-App inkl. Consent:
.\Setup-AppRegistration.ps1 -GrantAdminConsent
.EXAMPLE
# Bestehende App um Geraete-Rechte erweitern:
.\Setup-AppRegistration.ps1 -ClientId "51477347-...." -IncludeDeviceActions -GrantAdminConsent
.NOTES
Vorher einmalig: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
#>
[CmdletBinding()]
param(
[string]$DisplayName = 'Intune Manager',
[string]$ClientId,
[switch]$MultiTenant,
[switch]$ReadOnly,
[switch]$IncludeDeviceActions,
[switch]$IncludeOffboarding,
[switch]$GrantAdminConsent,
[switch]$EmitManifest
)
$ErrorActionPreference = 'Stop'
$GraphAppId = '00000003-0000-0000-c000-000000000000' # Microsoft Graph
# --- Benoetigte delegierte Berechtigungen zusammenstellen ---------------------
$perms = if ($ReadOnly) {
@('Group.Read.All','GroupMember.Read.All','User.Read.All',
'DeviceManagementApps.Read.All','DeviceManagementConfiguration.Read.All','offline_access')
} else {
@('Group.ReadWrite.All','GroupMember.ReadWrite.All','User.Read.All',
'DeviceManagementApps.ReadWrite.All','DeviceManagementConfiguration.ReadWrite.All','offline_access')
}
if ($IncludeDeviceActions) {
$perms += if ($ReadOnly) {
@('DeviceManagementManagedDevices.Read.All')
} else {
@('DeviceManagementManagedDevices.Read.All',
'DeviceManagementManagedDevices.ReadWrite.All',
'DeviceManagementManagedDevices.PrivilegedOperations.All')
}
}
if ($IncludeOffboarding) {
# Geraete ueber Intune/Autopilot/Entra entfernen + Recovery-Keys lesen.
# ACHTUNG: Loeschen braucht zusaetzlich Verzeichnis-/Intune-ROLLEN (Cloud
# Device Administrator / Intune Administrator) — nicht nur diese Scopes.
$perms += @(
'Device.ReadWrite.All',
'DeviceManagementManagedDevices.ReadWrite.All',
'DeviceManagementServiceConfig.ReadWrite.All',
'BitlockerKey.Read.All',
'DeviceLocalCredential.Read.All'
)
}
$perms = $perms | Select-Object -Unique
# --- Verbinden ----------------------------------------------------------------
Write-Host "[1/6] Mit Microsoft Graph verbinden (Admin noetig)..." -ForegroundColor Cyan
$connectScopes = @('Application.ReadWrite.All')
if ($GrantAdminConsent) { $connectScopes += 'DelegatedPermissionGrant.ReadWrite.All' }
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
Connect-MgGraph -Scopes $connectScopes -NoWelcome
$ctx = Get-MgContext
if (-not $ctx) { throw 'Keine Graph-Verbindung.' }
Write-Host " verbunden mit Tenant $($ctx.TenantId) als $($ctx.Account)" -ForegroundColor DarkGray
# --- Graph-Service-Principal + Berechtigungs-IDs aufloesen --------------------
Write-Host "[2/6] Graph-Berechtigungen aufloesen..." -ForegroundColor Cyan
$graphSp = (Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$GraphAppId'&`$select=id,oauth2PermissionScopes").value | Select-Object -First 1
if (-not $graphSp) { throw 'Graph-Service-Principal nicht gefunden.' }
$graphSpId = $graphSp.id
$scopeMap = @{}
foreach ($s in $graphSp.oauth2PermissionScopes) { $scopeMap[$s.value] = $s.id }
$resourceAccess = @()
foreach ($p in $perms) {
if (-not $scopeMap.ContainsKey($p)) { throw "Delegierte Berechtigung '$p' nicht im Graph-SP gefunden." }
$resourceAccess += @{ id = $scopeMap[$p]; type = 'Scope' }
}
$requiredResourceAccess = @(@{ resourceAppId = $GraphAppId; resourceAccess = $resourceAccess })
if ($EmitManifest) {
Write-Host "`n--- requiredResourceAccess (Manifest) ---" -ForegroundColor Yellow
($requiredResourceAccess | ConvertTo-Json -Depth 6)
Write-Host "-----------------------------------------`n" -ForegroundColor Yellow
}
# --- App anlegen oder aktualisieren -------------------------------------------
$signInAudience = if ($MultiTenant) { 'AzureADMultipleOrgs' } else { 'AzureADMyOrg' }
if ($ClientId) {
Write-Host "[3/6] Bestehende App $ClientId laden..." -ForegroundColor Cyan
$app = (Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$ClientId'&`$select=id,appId").value | Select-Object -First 1
if (-not $app) { throw "App mit appId $ClientId nicht gefunden." }
$objId = $app.id
$appId = $ClientId
$patch = @{
signInAudience = $signInAudience
isFallbackPublicClient = $true
requiredResourceAccess = $requiredResourceAccess
publicClient = @{ redirectUris = @(
'https://login.microsoftonline.com/common/oauth2/nativeclient'
"ms-appx-web://Microsoft.AAD.BrokerPlugin/$appId"
) }
}
Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/applications/$objId" `
-Body ($patch | ConvertTo-Json -Depth 8) -ContentType 'application/json' | Out-Null
Write-Host " App aktualisiert." -ForegroundColor Green
} else {
Write-Host "[3/6] Neue App '$DisplayName' anlegen..." -ForegroundColor Cyan
# Broker-Redirect-URI braucht die appId -> erst mit nativeclient anlegen,
# danach die Broker-URI per PATCH ergaenzen.
$create = @{
displayName = $DisplayName
signInAudience = $signInAudience
isFallbackPublicClient = $true
requiredResourceAccess = $requiredResourceAccess
publicClient = @{ redirectUris = @('https://login.microsoftonline.com/common/oauth2/nativeclient') }
}
$app = Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/applications' `
-Body ($create | ConvertTo-Json -Depth 8) -ContentType 'application/json'
$objId = $app.id
$appId = $app.appId
$patch = @{ publicClient = @{ redirectUris = @(
'https://login.microsoftonline.com/common/oauth2/nativeclient'
"ms-appx-web://Microsoft.AAD.BrokerPlugin/$appId"
) } }
Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/applications/$objId" `
-Body ($patch | ConvertTo-Json -Depth 6) -ContentType 'application/json' | Out-Null
Write-Host " App angelegt: appId $appId" -ForegroundColor Green
}
# --- Service-Principal (Enterprise-App) sicherstellen -------------------------
Write-Host "[4/6] Service-Principal sicherstellen..." -ForegroundColor Cyan
$sp = (Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$appId'&`$select=id").value | Select-Object -First 1
if (-not $sp) {
$sp = Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/servicePrincipals' `
-Body (@{ appId = $appId } | ConvertTo-Json) -ContentType 'application/json'
}
$spId = $sp.id
# --- Optional: Admin-Consent --------------------------------------------------
Write-Host "[5/6] Admin-Consent..." -ForegroundColor Cyan
if ($GrantAdminConsent) {
$scopeString = ($perms -join ' ')
# Bestehenden Grant fuer (Client -> Graph) suchen und ersetzen, sonst neu.
$existing = (Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$spId' and resourceId eq '$graphSpId'").value | Select-Object -First 1
$grantBody = @{
clientId = $spId
consentType = 'AllPrincipals'
resourceId = $graphSpId
scope = $scopeString
}
if ($existing) {
Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($existing.id)" `
-Body (@{ scope = $scopeString } | ConvertTo-Json) -ContentType 'application/json' | Out-Null
} else {
Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' `
-Body ($grantBody | ConvertTo-Json) -ContentType 'application/json' | Out-Null
}
Write-Host " Admin-Consent erteilt fuer: $scopeString" -ForegroundColor Green
} else {
Write-Host " uebersprungen (-GrantAdminConsent nicht gesetzt)." -ForegroundColor DarkGray
Write-Host " Consent im Portal: Entra -> App-Registrierungen -> $DisplayName -> API-Berechtigungen -> Administratorzustimmung erteilen" -ForegroundColor DarkGray
}
# --- Ergebnis -----------------------------------------------------------------
Write-Host "[6/6] Fertig." -ForegroundColor Cyan
Write-Host ""
Write-Host " Im Intune Manager eintragen:" -ForegroundColor White
Write-Host " Tenant ID : $($ctx.TenantId)"
Write-Host " Client ID : $appId"
Write-Host ""
Write-Host " Gesetzte delegierte Berechtigungen:" -ForegroundColor White
$perms | ForEach-Object { Write-Host " - $_" }
+1 -1
View File
@@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") {
$env:DOTNET_ROOT = "C:\Program Files\dotnet" $env:DOTNET_ROOT = "C:\Program Files\dotnet"
} }
$version = "0.1.26" $version = "0.1.35"
$src = "\\Mac\Home\ClaudePRJ\Intune Manager" $src = "\\Mac\Home\ClaudePRJ\Intune Manager"
$stage = "$env:TEMP\IntuneManagerStage" $stage = "$env:TEMP\IntuneManagerStage"
$installerDir = "$src\installer" $installerDir = "$src\installer"
+668 -106
View File
@@ -21,6 +21,7 @@ function Invoke-ApiHandler {
"POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body } "POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body }
"POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body } "POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body }
"DELETE /api/settings/logo" { return Remove-LogoEndpoint } "DELETE /api/settings/logo" { return Remove-LogoEndpoint }
"GET /api/update/check" { return Get-UpdateCheckEndpoint }
"POST /api/connect" { return Invoke-ConnectEndpoint } "POST /api/connect" { return Invoke-ConnectEndpoint }
"POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body } "POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body }
"POST /api/connect/start" { return Start-DeviceCodeConnect } "POST /api/connect/start" { return Start-DeviceCodeConnect }
@@ -53,9 +54,19 @@ function Invoke-ApiHandler {
"GET /api/policies/compliance" { return Get-CompliancePoliciesEndpoint } "GET /api/policies/compliance" { return Get-CompliancePoliciesEndpoint }
"GET /api/policies/configuration" { return Get-ConfigurationProfilesEndpoint } "GET /api/policies/configuration" { return Get-ConfigurationProfilesEndpoint }
"GET /api/policies/settingscatalog" { return Get-SettingsCatalogPoliciesEndpoint } "GET /api/policies/settingscatalog" { return Get-SettingsCatalogPoliciesEndpoint }
"GET /api/policies/administrativetemplate" { return Get-AdministrativeTemplatesEndpoint }
"POST /api/policies/export" { return Export-PoliciesEndpoint -Body $Body } "POST /api/policies/export" { return Export-PoliciesEndpoint -Body $Body }
"GET /api/policies/exports" { return Get-PolicyExportsEndpoint } "GET /api/policies/exports" { return Get-PolicyExportsEndpoint }
"POST /api/policies/import" { return Import-PoliciesEndpoint -Body $Body } "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 }
} }
# 2-segment fallbacks (z.B. /api/groups/<id>/members) # 2-segment fallbacks (z.B. /api/groups/<id>/members)
@@ -63,6 +74,9 @@ function Invoke-ApiHandler {
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") { if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") {
return Get-GroupMembersExportEndpoint -GroupId $matches[1] 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$") { if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") {
return Get-GroupMembersEndpoint -GroupId $matches[1] return Get-GroupMembersEndpoint -GroupId $matches[1]
@@ -123,6 +137,83 @@ function Invoke-ApiHandler {
return $null return $null
} }
# ============================================================
# Update-Check (neueste Release-Version von Gitea vergleichen)
# ============================================================
# Semver-Vergleich: 1 wenn A>B, -1 wenn A<B, 0 gleich. Fuehrendes 'v' und
# Pre-Release/Build-Suffixe werden ignoriert; fehlende Teile zaehlen als 0.
function Compare-SemVer {
param([string]$A, [string]$B)
$clean = {
param($s)
$s = ([string]$s).Trim().TrimStart('v','V')
$s = ($s -split '[-+ ]')[0]
@($s -split '\.' | ForEach-Object { [int]([regex]::Match($_, '\d+').Value -as [int]) })
}
$pa = & $clean $A
$pb = & $clean $B
$len = [Math]::Max($pa.Count, $pb.Count)
for ($i = 0; $i -lt $len; $i++) {
$x = if ($i -lt $pa.Count) { [int]$pa[$i] } else { 0 }
$y = if ($i -lt $pb.Count) { [int]$pb[$i] } else { 0 }
if ($x -gt $y) { return 1 }
if ($x -lt $y) { return -1 }
}
return 0
}
# Prueft die neueste Release-Version. Quelle standardmaessig das Gitea-Repo;
# ueber Settings.update.apiUrl / .token ueberschreibbar (falls Repo privat).
# Braucht KEINE Graph-Verbindung. Fehler werden still zurueckgegeben (kein Banner).
function Get-UpdateCheckEndpoint {
$current = [string]$script:ToolVersion
$out = @{ current = $current; latest = ''; updateAvailable = $false; releaseUrl = ''; error = '' }
$api = 'https://git.wende.it/api/v1/repos/marco/Intune-Manager/releases/latest'
$token = ''
try {
if ($script:Settings.update) {
if ($script:Settings.update.apiUrl) { $api = [string]$script:Settings.update.apiUrl }
if ($script:Settings.update.token) { $token = [string]$script:Settings.update.token }
}
} catch {}
$headers = @{ 'User-Agent' = 'IntuneManager' }
if ($token) { $headers['Authorization'] = "token $token" }
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
# Proxy: Hinter authentifizierten Unternehmens-Proxys (NTLM/Kerberos) scheitert
# Invoke-RestMethod sonst mit "407 Proxyauthentifizierung erforderlich", weil
# standardmaessig keine Anmeldeinformationen an den Proxy gesendet werden. Die
# angemeldeten Windows-Credentials an den (System-)Proxy durchreichen. Optional
# laesst sich ein expliziter Proxy ueber Settings.update.proxy setzen.
$proxyArgs = @{}
try {
$proxyUrl = ''
if ($script:Settings.update -and $script:Settings.update.proxy) { $proxyUrl = [string]$script:Settings.update.proxy }
if ($proxyUrl) {
$proxyArgs['Proxy'] = $proxyUrl
$proxyArgs['ProxyUseDefaultCredentials'] = $true
} elseif ([System.Net.WebRequest]::DefaultWebProxy) {
[System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials
}
} catch {}
try {
$rel = Invoke-RestMethod -Uri $api -Headers $headers -Method GET -TimeoutSec 8 @proxyArgs
$latest = ([string]$rel.tag_name).TrimStart('v','V')
$out.latest = $latest
$out.releaseUrl = [string]$rel.html_url
$out.updateAvailable = ((Compare-SemVer $latest $current) -gt 0)
} catch {
$out.error = $_.Exception.Message
Write-Host "[UPDATE] Versions-Check fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkGray
}
return $out
}
# ============================================================ # ============================================================
# Status & Connection # Status & Connection
# ============================================================ # ============================================================
@@ -928,7 +1019,30 @@ public class WinFocusHelper2 {
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
// Holt ein Fenster zuverlaessig in den Vordergrund und umgeht den Windows-
// Foreground-Lock: kurzer ALT-Tap (entsperrt SetForegroundWindow) + Anhaengen
// an den Input-Thread des aktuellen Vordergrundfensters (AttachThreadInput).
public static void ForceForeground(IntPtr hWnd) {
keybd_event(0x12, 0, 0, UIntPtr.Zero); // ALT down -> Foreground-Lock loesen
keybd_event(0x12, 0, 2, UIntPtr.Zero); // ALT up (KEYEVENTF_KEYUP = 2)
IntPtr fore = GetForegroundWindow();
uint foreThread = GetWindowThreadProcessId(fore, IntPtr.Zero);
uint thisThread = GetCurrentThreadId();
bool attached = false;
if (foreThread != 0 && foreThread != thisThread) {
attached = AttachThreadInput(foreThread, thisThread, true);
}
ShowWindow(hWnd, 9); // SW_RESTORE
BringWindowToTop(hWnd);
SetForegroundWindow(hWnd);
if (attached) { AttachThreadInput(foreThread, thisThread, false); }
}
} }
'@ -ErrorAction SilentlyContinue '@ -ErrorAction SilentlyContinue
$deadline = (Get-Date).AddSeconds(30) $deadline = (Get-Date).AddSeconds(30)
@@ -950,9 +1064,7 @@ public class WinFocusHelper2 {
return $true return $true
}, [IntPtr]::Zero) | Out-Null }, [IntPtr]::Zero) | Out-Null
if ($script:found -ne [IntPtr]::Zero) { if ($script:found -ne [IntPtr]::Zero) {
[WinFocusHelper2]::ShowWindow($script:found, 9) | Out-Null # SW_RESTORE [WinFocusHelper2]::ForceForeground($script:found)
[WinFocusHelper2]::BringWindowToTop($script:found) | Out-Null
[WinFocusHelper2]::SetForegroundWindow($script:found) | Out-Null
Start-Sleep -Milliseconds 800 Start-Sleep -Milliseconds 800
} }
Start-Sleep -Milliseconds 400 Start-Sleep -Milliseconds 400
@@ -1579,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 # Users
# ============================================================ # ============================================================
@@ -1635,17 +1860,28 @@ function Get-UserMemberOfEndpoint {
# Devices # Devices
# ============================================================ # ============================================================
# Verwaltungsart aus dem Graph-Feld 'managementAgent' ableiten.
# 'configurationManagerClientMdm' / 'configurationManagerClientMdmEas' -> Co-Managed
# (ConfigMgr + Intune), alles andere -> reines Intune (MDM).
function Get-ManagementType {
param([string]$Agent)
if ($Agent -match 'configurationManager') { return 'Co-Managed' }
return 'Intune'
}
function Search-DevicesEndpoint { function Search-DevicesEndpoint {
param($Query) param($Query)
$err = Test-Connected $err = Test-Connected
if ($err) { return $err } if ($err) { return $err }
$q = [string]$Query.q $q = ([string]$Query.q) -replace "'", "''" # OData-Escape fuer Apostroph
$os = [string]$Query.os $os = [string]$Query.os
$compliance = [string]$Query.compliance $compliance = [string]$Query.compliance
$top = 50 $top = 50
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime' # HINWEIS: 'managementState' ist KEIN gueltiges $select-Feld auf managedDevices
# -> fuehrt zu 400 BadRequest. Bewusst weggelassen.
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,serialNumber,model,manufacturer,enrolledDateTime,managementAgent'
# Ohne Suchbegriff: alle Geräte (erste Seite) # Ohne Suchbegriff: alle Geräte (erste Seite)
# Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName. # Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName.
@@ -1654,22 +1890,36 @@ function Search-DevicesEndpoint {
$snItems = @() $snItems = @()
if ($q -and $q.Length -ge 2) { if ($q -and $q.Length -ge 2) {
$filters = @() $osComp = @()
$nameFilter = "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))" if ($os) { $osComp += "operatingSystem eq '$os'" }
$filters += $nameFilter if ($compliance) { $osComp += "complianceState eq '$compliance'" }
if ($os) { $filters += "operatingSystem eq '$os'" } # Voller Namensfilter vs. nur deviceName. Manche Intune-Backends (DeviceFE)
if ($compliance) { $filters += "complianceState eq '$compliance'" } # unterstuetzen startswith NUR auf deviceName -> bei "Unsupported parameter"
$filterStr = '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' # (400) auf deviceName-only zurueckfallen. Kein $orderby mit $filter.
$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" $nameVariants = @(
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))",
$nameItems = @($resp.value) "startswith(deviceName,'$q')"
)
for ($vi = 0; $vi -lt $nameVariants.Count; $vi++) {
$filters = @($nameVariants[$vi]) + $osComp
$filterStr = '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&'
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top"
try {
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET
$nameItems = @($resp.value)
break
} catch {
if ($vi -eq $nameVariants.Count - 1) { throw } # letzter Versuch -> durchreichen
Write-Host " [DEVICES] Namensfilter nicht unterstuetzt -> Fallback auf deviceName-only" -ForegroundColor DarkYellow
}
}
# Seriennummer: exakter Vergleich (Graph unterstuetzt kein startswith auf serialNumber) # Seriennummer: exakter Vergleich (Graph unterstuetzt kein startswith auf serialNumber)
$snFilters = @("serialNumber eq '$q'") $snFilters = @("serialNumber eq '$q'")
if ($os) { $snFilters += "operatingSystem eq '$os'" } if ($os) { $snFilters += "operatingSystem eq '$os'" }
if ($compliance) { $snFilters += "complianceState eq '$compliance'" } if ($compliance) { $snFilters += "complianceState eq '$compliance'" }
$snFilter = '$filter=' + [uri]::EscapeDataString(($snFilters -join ' and ')) + '&' $snFilter = '$filter=' + [uri]::EscapeDataString(($snFilters -join ' and ')) + '&'
$snUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10" $snUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10"
try { try {
$snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET $snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET
$snItems = @($snResp.value) $snItems = @($snResp.value)
@@ -1679,7 +1929,9 @@ function Search-DevicesEndpoint {
if ($os) { $filters += "operatingSystem eq '$os'" } if ($os) { $filters += "operatingSystem eq '$os'" }
if ($compliance) { $filters += "complianceState eq '$compliance'" } if ($compliance) { $filters += "complianceState eq '$compliance'" }
$filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' } $filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' }
$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" # Kein $orderby: das Intune-DeviceFE-Backend lehnt es (mit/ohne Filter) teils ab.
# Die Sortierung macht ohnehin das Frontend.
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top"
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET
$nameItems = @($resp.value) $nameItems = @($resp.value)
} }
@@ -1699,12 +1951,14 @@ function Search-DevicesEndpoint {
OS = $d.operatingSystem OS = $d.operatingSystem
OSVersion = $d.osVersion OSVersion = $d.osVersion
ComplianceState = $d.complianceState ComplianceState = $d.complianceState
LastSync = $d.lastSyncDateTime LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState ManagementState = $d.managementState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = $d.serialNumber SerialNumber = $d.serialNumber
Model = $d.model Model = $d.model
Manufacturer = $d.manufacturer Manufacturer = $d.manufacturer
EnrolledDateTime = $d.enrolledDateTime EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
} }
} }
return @{ items = $items; count = $items.Count } return @{ items = $items; count = $items.Count }
@@ -1715,12 +1969,38 @@ function Get-DeviceEndpoint {
$err = Test-Connected $err = Test-Connected
if ($err) { return $err } if ($err) { return $err }
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,totalStorageSpaceInBytes,freeStorageSpaceInBytes' # 'managementState' entfernt: kein gueltiges $select-Feld auf managedDevices (400).
$d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,managementAgent,totalStorageSpaceInBytes,freeStorageSpaceInBytes'
$d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET
$totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null } $totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null }
$freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null } $freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null }
# Autopilot-Identity per Seriennummer nachladen (nur Windows, fehlertolerant).
# HINWEIS: Ein echtes "Import-/Registrierungsdatum" liefert Graph nicht; verfuegbar
# sind nur Profil-Zuweisung (deploymentProfileAssignedDateTime) und letzter Kontakt.
$inAutopilot = $false
$apProfileAssign = $null
$apLastContact = $null
$sn = [string]$d.serialNumber
if ($sn -and ([string]$d.operatingSystem).ToLower() -eq 'windows') {
try {
$snEsc = $sn -replace "'", "''"
$apFilt = [Uri]::EscapeDataString("contains(serialNumber,'$snEsc')")
$apSel = 'id,serialNumber,deploymentProfileAssignedDateTime,lastContactedDateTime,enrollmentState'
$apUri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$apFilt&`$select=$apSel&`$top=1"
$apResp = Invoke-MgGraphRequestRetry -Uri $apUri -Method GET
$autop = @($apResp.value)[0]
if ($autop) {
$inAutopilot = $true
$apProfileAssign = ConvertTo-IsoDate $autop.deploymentProfileAssignedDateTime
$apLastContact = ConvertTo-IsoDate $autop.lastContactedDateTime
}
} catch {
Write-Host " [DEVICE] Autopilot-Lookup (SN=$sn): $($_.Exception.Message)" -ForegroundColor DarkYellow
}
}
return @{ return @{
Id = $d.id Id = $d.id
DeviceName = $d.deviceName DeviceName = $d.deviceName
@@ -1729,12 +2009,14 @@ function Get-DeviceEndpoint {
OS = $d.operatingSystem OS = $d.operatingSystem
OSVersion = $d.osVersion OSVersion = $d.osVersion
ComplianceState = $d.complianceState ComplianceState = $d.complianceState
LastSync = $d.lastSyncDateTime LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState ManagementState = $d.managementState
ManagementAgent = [string]$d.managementAgent
ManagementType = Get-ManagementType ([string]$d.managementAgent)
SerialNumber = $d.serialNumber SerialNumber = $d.serialNumber
Model = $d.model Model = $d.model
Manufacturer = $d.manufacturer Manufacturer = $d.manufacturer
EnrolledDateTime = $d.enrolledDateTime EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
Imei = $d.imei Imei = $d.imei
WiFiMac = $d.wiFiMacAddress WiFiMac = $d.wiFiMacAddress
AzureADDeviceId = $d.azureADDeviceId AzureADDeviceId = $d.azureADDeviceId
@@ -1743,6 +2025,9 @@ function Get-DeviceEndpoint {
OwnerType = $d.managedDeviceOwnerType OwnerType = $d.managedDeviceOwnerType
TotalStorageGB = $totalGB TotalStorageGB = $totalGB
FreeStorageGB = $freeGB FreeStorageGB = $freeGB
InAutopilot = $inAutopilot
AutopilotProfileAssigned = $apProfileAssign
AutopilotLastContacted = $apLastContact
} }
} }
@@ -1970,8 +2255,49 @@ function Get-AppInstallReportEndpoint {
return @{ items = $items; count = $items.Count } return @{ items = $items; count = $items.Count }
} }
# Intune-Report via asynchronem Export-Job (exportJobs) -> CSV-Zeilen.
# Kein 'select' (unbekannte Spalten wuerden 400 ausloesen) — der Aufrufer
# greift tolerant auf die tatsaechlich gelieferten Spalten zu.
function Get-IntuneReportRows {
param(
[Parameter(Mandatory=$true)][string]$ReportName,
[string]$Filter = '',
[int]$TimeoutSec = 120
)
$jobBody = @{ reportName = $ReportName; filter = $Filter } | ConvertTo-Json -Compress
$job = Invoke-MgGraphRequestRetry `
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' `
-Method POST -Body $jobBody -ContentType 'application/json'
$jobId = $job.id
$status = $job.status
$waited = 0
while ($status -ne 'completed' -and $status -ne 'failed' -and $waited -lt $TimeoutSec) {
Start-Sleep -Seconds 3
$waited += 3
$job = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$jobId"
$status = $job.status
}
if ($status -ne 'completed') { throw "Export-Job '$ReportName' nicht abgeschlossen (Status: $status nach $waited s)" }
$tmpZip = [System.IO.Path]::GetTempFileName() + '.zip'
$tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "IntuneReport_$jobId")
try {
Invoke-WebRequest -Uri $job.url -OutFile $tmpZip -UseBasicParsing
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($tmpZip, $tmpDir)
$csv = Get-ChildItem -Path $tmpDir -Filter '*.csv' | Select-Object -First 1
if (-not $csv) { throw "Keine CSV im Export-ZIP gefunden" }
return @(Import-Csv -Path $csv.FullName -Encoding UTF8)
} finally {
Remove-Item -Path $tmpZip -Force -ErrorAction SilentlyContinue
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Get-AppDeviceStatusEndpoint { function Get-AppDeviceStatusEndpoint {
# Verwendet getDeviceInstallStatusReport (synchron, paginiert) statt deviceStatuses. # Per-Device-Installationsstatus einer App via Export-Job 'DeviceInstallStatusByApp'.
# (Der frueher genutzte synchrone Endpoint getDeviceInstallStatusReport existiert
# nicht mehr -> 400 "Resource not found for the segment".)
param([hashtable]$Query) param([hashtable]$Query)
$err = Test-Connected $err = Test-Connected
if ($err) { return $err } if ($err) { return $err }
@@ -1981,58 +2307,38 @@ function Get-AppDeviceStatusEndpoint {
return @{ __status = 400; error = "appId fehlt" } return @{ __status = 400; error = "appId fehlt" }
} }
Write-Host "[DEVSTATUS] Lade Install-Status fuer App $appId via Reports-API..." -ForegroundColor DarkCyan Write-Host "[DEVSTATUS] Export-Job (DeviceInstallStatusByApp) fuer App $appId..." -ForegroundColor DarkCyan
$pageSize = 50 try {
$skip = 0 $rows = @(Get-IntuneReportRows -ReportName 'DeviceInstallStatusByApp' -Filter "(ApplicationId eq '$appId')")
$allRows = @() } catch {
$cols = $null $msg = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {}
do { return @{ __status = 500; error = "Graph-Fehler: $msg" }
$bodyJson = "{""filter"":""(ApplicationId eq '$appId')"",""select"":[],""skip"":$skip,""top"":$pageSize,""orderBy"":[]}"
try {
$resp = Invoke-MgGraphRequestRetry `
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/getDeviceInstallStatusReport' `
-Method POST -Body $bodyJson -ContentType 'application/json'
} catch {
return @{ __status = 500; error = "Graph-Fehler: $($_.Exception.Message)" }
}
if (-not $cols) {
$cols = @($resp.Schema | ForEach-Object { $_.Column })
Write-Host " [DEVSTATUS] Spalten: $($cols -join ',')" -ForegroundColor DarkGray
}
$rows = @($resp.Values)
$allRows += $rows
$skip += $pageSize
} while ($rows.Count -eq $pageSize)
Write-Host " -> $($allRows.Count) Eintraege" -ForegroundColor DarkGray
if (-not $cols -or $allRows.Count -eq 0) {
return @{ items = @(); count = 0 }
} }
Write-Host " -> $($rows.Count) Eintraege" -ForegroundColor DarkGray
if ($rows.Count -eq 0) { return @{ items = @(); count = 0 } }
function ColIdx($name) { [Array]::IndexOf($cols, $name) } Write-Host " [DEVSTATUS] Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray
$iDevice = ColIdx 'DeviceName'
$iUser = ColIdx 'UserName'
$iState = ColIdx 'InstallState'
$iDetail = ColIdx 'InstallStateDetail'
$iErr = ColIdx 'ErrorCode'
$iOs = ColIdx 'OSVersion'
$iSync = ColIdx 'LastModifiedDateTime'
if ($iOs -lt 0) { $iOs = ColIdx 'OsVersion' }
if ($iSync -lt 0) { $iSync = ColIdx 'LastSyncDateTime' }
$result = @($allRows | ForEach-Object { # Spaltennamen des Reports koennen variieren -> tolerant mit Fallbacks lesen.
$col = {
param($row, [string[]]$names)
foreach ($n in $names) {
$p = $row.PSObject.Properties[$n]
if ($p -and $null -ne $p.Value -and [string]$p.Value -ne '') { return [string]$p.Value }
}
return ''
}
$result = @($rows | ForEach-Object {
$r = $_ $r = $_
[pscustomobject]@{ [pscustomobject]@{
DeviceName = if ($iDevice -ge 0) { [string]$r[$iDevice] } else { '' } DeviceName = & $col $r @('DeviceName')
UserName = if ($iUser -ge 0) { [string]$r[$iUser] } else { '' } UserName = & $col $r @('UserName','UserPrincipalName')
InstallState = if ($iState -ge 0) { [string]$r[$iState] } else { '' } InstallState = & $col $r @('InstallState_loc','InstallState','AppInstallState_loc','AppInstallState')
InstallStateDetail = if ($iDetail -ge 0) { [string]$r[$iDetail] } else { '' } InstallStateDetail = & $col $r @('InstallStateDetail_loc','InstallStateDetail','AppInstallStateDetail_loc','AppInstallStateDetail')
ErrorCode = if ($iErr -ge 0) { [string]$r[$iErr] } else { '' } ErrorCode = & $col $r @('ErrorCode','HexErrorCode')
OsVersion = if ($iOs -ge 0) { [string]$r[$iOs] } else { '' } OsVersion = & $col $r @('OSVersion','OsVersion','Platform')
LastSyncDateTime = if ($iSync -ge 0) { [string]$r[$iSync] } else { '' } LastSyncDateTime = & $col $r @('LastModifiedDateTime','LastSyncDateTime')
} }
}) })
@@ -2597,6 +2903,53 @@ function Invoke-FilePickerEndpoint {
return @{ ok = $true; path = $path } return @{ ok = $true; path = $path }
} }
function Show-OpenFolderDialog {
# Wie Show-OpenFileDialog, aber FolderBrowserDialog in einem STA-Prozess.
param([string]$Title = "Ordner auswaehlen")
$tEsc = $Title -replace "'", "''"
$inner = @"
`$ProgressPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Windows.Forms
`$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
`$dlg.Description = '$tEsc'
`$owner = New-Object System.Windows.Forms.Form
`$owner.TopMost = `$true
`$owner.ShowInTaskbar = `$false
`$owner.WindowState = 'Minimized'
`$owner.Show(); `$owner.Activate()
`$r = `$dlg.ShowDialog(`$owner)
`$owner.Dispose()
if (`$r -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(`$dlg.SelectedPath) }
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner))
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "powershell.exe"
$psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -EncodedCommand $encoded"
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.CreateNoWindow = $true
try {
$proc = [System.Diagnostics.Process]::Start($psi)
$path = $proc.StandardOutput.ReadToEnd()
$proc.WaitForExit()
} catch {
throw "Ordner-Dialog-Prozess konnte nicht gestartet werden: $($_.Exception.Message)"
}
return ([string]$path).Trim()
}
function Invoke-FolderPickerEndpoint {
param($Body)
$title = if ($Body -and $Body.title) { [string]$Body.title } else { "Git-Repo-Ordner auswaehlen" }
try {
$path = Show-OpenFolderDialog -Title $title
} catch {
return @{ __status = 500; error = "Ordner-Dialog fehlgeschlagen: $($_.Exception.Message)" }
}
if (-not $path) { return @{ ok = $true; cancelled = $true } }
return @{ ok = $true; path = $path }
}
# App-Typ (@odata.type) -> unterstuetzter Content-Update-Pfad + erlaubte Endung. # App-Typ (@odata.type) -> unterstuetzter Content-Update-Pfad + erlaubte Endung.
# Phase 1: nur win32LobApp/.intunewin aktiv; MSI/MSIX kommen in Phase 2/3. # Phase 1: nur win32LobApp/.intunewin aktiv; MSI/MSIX kommen in Phase 2/3.
function Get-AppContentTypeMap { function Get-AppContentTypeMap {
@@ -2708,23 +3061,29 @@ function Add-AppAssignmentEndpoint {
if ($intent -notin @("required","available")) { if ($intent -notin @("required","available")) {
return @{ __status = 400; error = "intent muss 'required' oder 'available' sein" } return @{ __status = 400; error = "intent muss 'required' oder 'available' sein" }
} }
$target = [string]$Body.target # Ein ODER mehrere Ziele akzeptieren: 'targets' (Array) hat Vorrang, sonst 'target'.
if ([string]::IsNullOrWhiteSpace($target)) { $targets = @()
return @{ __status = 400; error = "target fehlt (ALL_USERS / ALL_DEVICES / <group-guid>)" } if ($Body.targets) { $targets = @($Body.targets | ForEach-Object { [string]$_ }) }
elseif ($Body.target) { $targets = @([string]$Body.target) }
$targets = @($targets | Where-Object { $_ -and $_.Trim() })
if ($targets.Count -eq 0) {
return @{ __status = 400; error = "target/targets fehlt (ALL_USERS / ALL_DEVICES / <group-guid>)" }
} }
# Bei Group-Target: zumindest grobe Hex/GUID-Form pruefen damit wir keine # Jedes Group-Target grob auf Hex/GUID-Form pruefen (keine Garbage an Graph).
# Garbage an Graph schicken. foreach ($t in $targets) {
if ($target -notin @("ALL_USERS","ALL_DEVICES") -and $target -notmatch '^[0-9a-fA-F-]{8,}$') { if ($t -notin @("ALL_USERS","ALL_DEVICES") -and $t -notmatch '^[0-9a-fA-F-]{8,}$') {
return @{ __status = 400; error = "Ungueltige target-GUID: $target" } return @{ __status = 400; error = "Ungueltige target-GUID: $t" }
}
} }
# App-Cache fuer logging # App-Cache fuer logging
$cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1
$appName = if ($cachedApp) { $cachedApp.AppName } else { $AppId } $appName = if ($cachedApp) { $cachedApp.AppName } else { $AppId }
Write-Host "[ADD ASSIGN] $appName -> intent=$intent target=$target" -ForegroundColor Yellow Write-Host "[ADD ASSIGN] $appName -> intent=$intent targets=$($targets -join ', ')" -ForegroundColor Yellow
try { try {
Add-GraphAppAssignment -AppId $AppId -Intent $intent -GroupId $target # EIN Request fuer alle Ziele: bestehende Zuweisungen werden gemergt.
Add-GraphAppAssignment -AppId $AppId -Intent $intent -GroupId $targets
} catch { } catch {
$graphBody = $null $graphBody = $null
try { $graphBody = $_.ErrorDetails.Message } catch {} try { $graphBody = $_.ErrorDetails.Message } catch {}
@@ -2739,38 +3098,46 @@ function Add-AppAssignmentEndpoint {
return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody }
} }
# Cache aktualisieren — neue Zuweisung im App-Eintrag ergaenzen # Cache aktualisieren — neue Zuweisung(en) im App-Eintrag ergaenzen.
# (Das Frontend laedt danach ohnehin neu; das haelt die UI aber sofort konsistent.)
if ($cachedApp) { if ($cachedApp) {
$entry = if ($target -eq "ALL_USERS") { $lookup = @{}
@{ GroupId = "ALL_USERS"; GroupName = "All Users"; IsNative = $true } foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName }
} elseif ($target -eq "ALL_DEVICES") { foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName }
@{ GroupId = "ALL_DEVICES"; GroupName = "All Devices"; IsNative = $true } foreach ($target in $targets) {
} else { $entry = if ($target -eq "ALL_USERS") {
# Group-Namen aus den geladenen Gruppen oder Graph nachschlagen @{ GroupId = "ALL_USERS"; GroupName = "All Users"; IsNative = $true }
$groupName = $target } elseif ($target -eq "ALL_DEVICES") {
$lookup = @{} @{ GroupId = "ALL_DEVICES"; GroupName = "All Devices"; IsNative = $true }
foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName } } else {
foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName } $groupName = $target
if ($lookup.ContainsKey($target)) { $groupName = $lookup[$target] } if ($lookup.ContainsKey($target)) { $groupName = $lookup[$target] }
else { else {
try { try {
$g = Get-GraphGroupById -Id $target -Property @("id","displayName") $g = Get-GraphGroupById -Id $target -Property @("id","displayName")
if ($g.displayName) { $groupName = [string]$g.displayName } if ($g.displayName) { $groupName = [string]$g.displayName }
} catch {} } catch {}
}
@{ GroupId = $target; GroupName = $groupName; IsNative = $false }
}
$exists = if ($intent -eq "available") {
@($cachedApp.AvailableGroups | Where-Object { $_.GroupId -eq $entry.GroupId }).Count -gt 0
} else {
@($cachedApp.RequiredGroups | Where-Object { $_.GroupId -eq $entry.GroupId }).Count -gt 0
}
if ($exists) { continue }
if ($intent -eq "available") {
$cachedApp.AvailableGroups = @($cachedApp.AvailableGroups + $entry)
$cachedApp.AvailableCount = $cachedApp.AvailableGroups.Count
} else {
$cachedApp.RequiredGroups = @($cachedApp.RequiredGroups + $entry)
$cachedApp.RequiredCount = $cachedApp.RequiredGroups.Count
} }
@{ GroupId = $target; GroupName = $groupName; IsNative = $false }
}
if ($intent -eq "available") {
$cachedApp.AvailableGroups = @($cachedApp.AvailableGroups + $entry)
$cachedApp.AvailableCount = $cachedApp.AvailableGroups.Count
} else {
$cachedApp.RequiredGroups = @($cachedApp.RequiredGroups + $entry)
$cachedApp.RequiredCount = $cachedApp.RequiredGroups.Count
} }
} }
Write-Host "[ADD ASSIGN] OK" -ForegroundColor Green Write-Host "[ADD ASSIGN] OK ($($targets.Count) Ziel(e))" -ForegroundColor Green
return @{ ok = $true; appId = $AppId; intent = $intent; target = $target } return @{ ok = $true; appId = $AppId; intent = $intent; targets = @($targets); count = $targets.Count }
} }
function Remove-AppAssignmentEndpoint { function Remove-AppAssignmentEndpoint {
@@ -3359,3 +3726,198 @@ footer code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;bac
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path return $path
} }
# HTML-Report fuers Geraete-Offboarding — pro Geraet die ausgefuehrten Loeschungen
# (Entra/Intune/Autopilot) mit Status + Meldung. Liegt in Api.ps1 (BOM) wegen Umlauten.
function New-OffboardHtmlReport {
param($Devices, $Results, [int]$Success, [int]$Errors, [int]$Total, [string]$EntraAction, [bool]$DoIntune, [bool]$DoAutopilot)
Add-Type -AssemblyName System.Web
function _e { param($s) if ($null -eq $s) { return "" } return [System.Web.HttpUtility]::HtmlEncode([string]$s) }
$ts = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
$tsHuman = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
$name = "offboard-report-$ts.html"
$path = Join-Path $script:Config.ReportDir $name
$user = $env:USERNAME
$machine = $env:COMPUTERNAME
$tenant = if ($script:State.TenantId) { $script:State.TenantId } else { "" }
$account = if ($script:State.Account) { $script:State.Account } else { "" }
$devCount = @($Devices).Count
$bannerCls = if ($Errors -gt 0) { "banner-err" } else { "banner-ok" }
$bannerTxt = if ($Errors -gt 0) { "$Errors von $Total Aktion(en) fehlgeschlagen - Details unten" } else { "Alle $Success Aktion(en) erfolgreich" }
# Dienst-Zusammenfassung (was angefordert wurde)
$svcParts = @()
if ($EntraAction -eq 'delete') { $svcParts += "Entra ID: geloescht" }
elseif ($EntraAction -eq 'disable') { $svcParts += "Entra ID: deaktiviert" }
if ($DoIntune) { $svcParts += "Intune: geloescht" }
if ($DoAutopilot) { $svcParts += "Autopilot: geloescht" }
$svcTxt = if ($svcParts.Count -gt 0) { ($svcParts -join " &middot; ") } else { "keine" }
# Ergebnisse pro Geraet gruppieren (Reihenfolge = Geraeteliste)
$byDev = [ordered]@{}
foreach ($d in @($Devices)) { $dn = [string]$d.deviceName; if ($dn -and -not $byDev.Contains($dn)) { $byDev[$dn] = @() } }
foreach ($r in @($Results)) {
$dn = [string]$r.deviceName
if (-not $byDev.Contains($dn)) { $byDev[$dn] = @() }
$byDev[$dn] += $r
}
$devBlocks = ""
foreach ($dn in @($byDev.Keys)) {
$rows = @($byDev[$dn])
$okC = @($rows | Where-Object { $_.success }).Count
$errC = @($rows | Where-Object { -not $_.success }).Count
$rowsHtml = ""
foreach ($r in $rows) {
$cls = if ($r.success) { "ok" } else { "err" }
$ico = if ($r.success) { "&#10003;" } else { "&#33;" }
$lbl = if ($r.success) { "Erfolg" } else { "Fehler" }
$msg = if ($r.error) { _e $r.error } else { "" }
$rowsHtml += "<tr class='row-$cls'>" +
"<td class='c-status'><span class='status-pill status-$cls'>$ico $lbl</span></td>" +
"<td class='c-svc'>$(_e $r.service)</td>" +
"<td class='c-act'>$(_e $r.action)</td>" +
"<td class='c-msg'>$msg</td></tr>"
}
if (-not $rowsHtml) { $rowsHtml = "<tr><td colspan='4' class='c-msg'>Keine ausgefuehrten Aktionen.</td></tr>" }
$stats = ""
if ($okC -gt 0) { $stats += "<span class='hd-pill hd-ok'>$okC OK</span>" }
if ($errC -gt 0) { $stats += "<span class='hd-pill hd-err'>$errC Fehler</span>" }
$devBlocks += "<details class='dev-block' open><summary class='dev-head'>" +
"<span class='dev-name'>$(_e $dn)</span><span class='dev-stats'>$stats</span></summary>" +
"<div class='dev-body'><table class='res-table'><thead><tr>" +
"<th class='c-status'>Status</th><th class='c-svc'>Dienst</th><th class='c-act'>Aktion</th><th class='c-msg'>Meldung</th>" +
"</tr></thead><tbody>$rowsHtml</tbody></table></div></details>"
}
if (-not $devBlocks) { $devBlocks = "<div class='empty'>Keine Ergebnisse.</div>" }
$html = @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune Offboarding-Report - $tsHuman</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root{
--black-100:#0A0E15;--black-80:#373F4E;--black-60:#667085;
--white-100:#FFFFFF;--white-90:#F0F1F5;--white-80:#E0E4EB;--white-70:#D1D6E0;
--blue-60:#3566B6;--blue-10:#E0ECFF;
--success-100:#008072;--success-60:#A0E3D8;--success-10:#D0FBF5;
--error-100:#B21919;--error-60:#EA9E9E;--error-10:#FBE0E0;
--warning-100:#8F7200;--warning-60:#F8E081;--warning-10:#FFF3C2;
--bg:#F7FAFD;--surface:#FFF;--surface-soft:#F4F7FB;--hairline:var(--white-70);--hairline-soft:var(--white-90);
--text-primary:var(--black-100);--text-secondary:var(--black-80);--text-muted:var(--black-60);
--r-sm:6px;--r-md:10px;--r-lg:14px;--shadow-soft:0 1px 2px rgba(23,64,130,.04),0 2px 6px rgba(23,64,130,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{font-family:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text-secondary);line-height:1.5;-webkit-font-smoothing:antialiased}
body{padding:32px 16px 80px}
.container{max-width:1100px;margin:0 auto}
.head{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:24px 28px;margin-bottom:20px;box-shadow:var(--shadow-soft)}
.head-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:9px;background:var(--blue-60);color:#fff;display:grid;place-items:center;font-size:20px;font-weight:700}
.title{font-size:22px;font-weight:700;letter-spacing:-.015em;color:var(--text-primary)}
.subtitle{font-size:13px;color:var(--text-muted);margin-top:2px}
.head-meta{display:flex;flex-wrap:wrap;gap:6px}
.meta-pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;background:var(--surface-soft);border:1px solid var(--hairline);padding:5px 10px;border-radius:999px;color:var(--text-secondary);font-weight:500}
.meta-pill .lbl{color:var(--text-muted);text-transform:uppercase;font-size:10px;letter-spacing:.04em;font-weight:600}
.meta-pill code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px}
.banner{margin-top:18px;padding:12px 16px;border-radius:var(--r-md);font-size:13.5px;font-weight:500;display:flex;align-items:center;gap:10px;border:1px solid transparent}
.banner-ok{background:var(--success-10);border-color:var(--success-60);color:var(--success-100)}
.banner-err{background:var(--error-10);border-color:var(--error-60);color:var(--error-100)}
.banner-icon{width:22px;height:22px;border-radius:50%;display:inline-grid;place-items:center;font-size:13px;font-weight:700}
.banner-ok .banner-icon{background:var(--success-60);color:var(--success-100)}
.banner-err .banner-icon{background:var(--error-60);color:var(--error-100)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px}
.kpi{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-md);padding:18px}
.kpi-num{font-size:30px;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums;color:var(--text-primary)}
.kpi-lbl{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600;margin-top:6px}
.kpi.ok .kpi-num{color:var(--success-100)}
.kpi.err .kpi-num{color:var(--error-100)}
.section{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:22px 24px;margin-bottom:18px;box-shadow:var(--shadow-soft)}
.section-title{font-size:15px;font-weight:600;color:var(--text-primary);margin-bottom:4px}
.section-sub{font-size:12px;color:var(--text-muted);margin-bottom:14px}
.dev-block{border:1px solid var(--hairline);border-radius:var(--r-md);overflow:hidden;margin-bottom:10px;background:var(--surface)}
.dev-head{cursor:pointer;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;list-style:none}
.dev-head::-webkit-details-marker{display:none}
.dev-name{font-size:14px;font-weight:600;color:var(--text-primary)}
.dev-stats{display:flex;gap:6px}
.hd-pill{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px}
.hd-ok{background:var(--success-10);color:var(--success-100)}
.hd-err{background:var(--error-10);color:var(--error-100)}
.dev-body{border-top:1px solid var(--hairline-soft)}
.res-table{width:100%;border-collapse:collapse;font-size:13px}
.res-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);font-weight:600;padding:8px 16px;background:var(--surface-soft);border-bottom:1px solid var(--hairline-soft)}
.res-table td{padding:9px 16px;border-bottom:1px solid var(--hairline-soft);vertical-align:top}
.res-table tr:last-child td{border-bottom:none}
.c-status{width:120px}.c-svc{width:120px;font-weight:600;color:var(--text-primary)}.c-act{width:130px}
.c-msg{color:var(--error-100)}
.status-pill{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.status-ok{background:var(--success-10);color:var(--success-100);border-color:var(--success-60)}
.status-err{background:var(--error-10);color:var(--error-100);border-color:var(--error-60)}
.empty{padding:18px;text-align:center;color:var(--text-muted);font-size:13px;background:var(--surface-soft);border-radius:var(--r-md);border:1px dashed var(--hairline)}
footer{text-align:center;color:var(--text-muted);font-size:11.5px;margin-top:24px}
footer code{font-family:ui-monospace,Menlo,Consolas,monospace;background:var(--surface);padding:1px 6px;border-radius:4px;border:1px solid var(--hairline)}
@media print{body{background:#fff;padding:0}.section,.head,.kpi,.dev-block{box-shadow:none;break-inside:avoid}}
</style>
</head>
<body>
<div class="container">
<header class="head">
<div class="head-top">
<div class="brand">
<div class="logo">I</div>
<div>
<div class="title">Intune Offboarding-Report</div>
<div class="subtitle">$tsHuman &middot; ausgefuehrt von $(_e $user)</div>
</div>
</div>
<div class="head-meta">
"@
if ($account) { $html += "<span class='meta-pill'><span class='lbl'>Account</span> $(_e $account)</span>" }
if ($tenant) { $html += "<span class='meta-pill'><span class='lbl'>Tenant</span> <code>$(_e $tenant)</code></span>" }
$html += "<span class='meta-pill'><span class='lbl'>Host</span> $(_e $machine)</span>"
$html += @"
</div>
</div>
<div class="banner $bannerCls">
<span class="banner-icon">$(if ($Errors -gt 0) { '!' } else { '&#10003;' })</span>
<span>$bannerTxt</span>
</div>
</header>
<div class="kpis">
<div class="kpi tot"><div class="kpi-num">$devCount</div><div class="kpi-lbl">Geraete</div></div>
<div class="kpi tot"><div class="kpi-num">$Total</div><div class="kpi-lbl">Aktionen</div></div>
<div class="kpi ok"><div class="kpi-num">$Success</div><div class="kpi-lbl">Erfolgreich</div></div>
<div class="kpi err"><div class="kpi-num">$Errors</div><div class="kpi-lbl">Fehler</div></div>
</div>
<div class="section">
<div class="section-title">Angeforderte Dienste</div>
<div class="section-sub">$svcTxt</div>
</div>
<div class="section">
<div class="section-title">Ergebnisse pro Geraet</div>
<div class="section-sub">Jeder Block zeigt die ausgefuehrten Loeschungen/Aenderungen mit Status und Meldung.</div>
$devBlocks
</div>
<footer>
Intune Manager &middot; Web Edition <code>v$(_e $script:ToolVersion)</code> &middot; Offboarding-Report
</footer>
</div>
</body>
</html>
"@
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path
}
+76 -18
View File
@@ -33,7 +33,13 @@ function Invoke-MgGraphRequestRetry {
$Body, $Body,
[string]$ContentType, [string]$ContentType,
[hashtable]$Headers, [hashtable]$Headers,
[int]$MaxRetries = 3 [int]$MaxRetries = 3,
# Rohes JSON von Graph holen und selbst parsen. Noetig fuer Policy-Exporte:
# Invoke-MgGraphRequest liefert unter Windows PowerShell 5.1 im Hashtable-
# Modus Ein-Element-Collections als Skalar (z.B. printerNames), was beim
# Re-Import 400 "A 'StartArray' node was expected" ausloest. -OutputType Json
# umgeht das: ConvertFrom-Json bewahrt Arrays.
[switch]$AsRawJson
) )
$attempt = 0 $attempt = 0
while ($true) { while ($true) {
@@ -42,6 +48,13 @@ function Invoke-MgGraphRequestRetry {
if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params.Body = $Body } if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params.Body = $Body }
if ($ContentType) { $params.ContentType = $ContentType } if ($ContentType) { $params.ContentType = $ContentType }
if ($Headers) { $params.Headers = $Headers } if ($Headers) { $params.Headers = $Headers }
if ($AsRawJson) {
$params.OutputType = 'Json'
$raw = Invoke-MgGraphRequest @params
if ([string]::IsNullOrWhiteSpace([string]$raw)) { return $null }
if ($PSVersionTable.PSVersion.Major -ge 6) { return ($raw | ConvertFrom-Json -AsHashtable) }
return ($raw | ConvertFrom-Json)
}
return Invoke-MgGraphRequest @params return Invoke-MgGraphRequest @params
} catch { } catch {
$msg = $_.Exception.Message $msg = $_.Exception.Message
@@ -58,17 +71,26 @@ function Invoke-MgGraphRequestRetry {
Start-Sleep -Seconds $wait Start-Sleep -Seconds $wait
continue continue
} }
# Nicht-Retry-Fehler: den Graph-Fehler-Body (mit dem eigentlichen Grund)
# an die Meldung haengen — sonst steht im Log nur "BadRequest".
$gbody = $null
try { $gbody = [string]$_.ErrorDetails.Message } catch {}
if ($gbody) { throw [System.Exception]::new("$msg | Graph: $gbody", $_.Exception) }
throw throw
} }
} }
} }
function Get-GraphPaged { function Get-GraphPaged {
param([Parameter(Mandatory=$true)][string]$Uri) param(
[Parameter(Mandatory=$true)][string]$Uri,
# An Invoke-MgGraphRequestRetry durchreichen: bewahrt Arrays fuer Exporte.
[switch]$AsRawJson
)
$results = @() $results = @()
$next = $Uri $next = $Uri
do { do {
$response = Invoke-MgGraphRequestRetry -Uri $next -Method GET $response = Invoke-MgGraphRequestRetry -Uri $next -Method GET -AsRawJson:$AsRawJson
if ($response.value) { $results += $response.value } if ($response.value) { $results += $response.value }
$next = $response.'@odata.nextLink' $next = $response.'@odata.nextLink'
} while ($next) } while ($next)
@@ -376,21 +398,51 @@ function Add-GraphAppAssignment {
param( param(
[string]$AppId, [string]$AppId,
[string]$Intent, # "available" | "required" [string]$Intent, # "available" | "required"
[string]$GroupId # GUID oder "ALL_USERS" / "ALL_DEVICES" [string[]]$GroupId # eine ODER mehrere: GUID(s) und/oder "ALL_USERS"/"ALL_DEVICES"
) )
$target = switch ($GroupId) { # WICHTIG: Die /assign-Action ERSETZT die komplette Zuweisungsliste der App.
"ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } } # Ein einzelnes POST wuerde also alle bestehenden Zuweisungen loeschen. Daher:
"ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } } # bestehende Zuweisungen laden, die neuen mergen (Duplikate ueberspringen) und
default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = $GroupId } } # den VOLLEN Satz in EINEM Request schicken. Das haelt mehrere neue Gruppen
# zusammen (kein Read-after-Write-Rennen) und bewahrt vorhandene Zuweisungen.
$newTargets = @()
foreach ($gid in @($GroupId)) {
if ([string]::IsNullOrWhiteSpace([string]$gid)) { continue }
$newTargets += switch ([string]$gid) {
"ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } }
"ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } }
default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = [string]$gid } }
}
} }
$body = @{
mobileAppAssignments = @(@{ $existing = Get-GraphMobileAppAssignments -AppId $AppId
"@odata.type" = "#microsoft.graph.mobileAppAssignment" $assignments = @()
$seen = @{} # Schluessel intent|type|groupId -> Duplikate vermeiden
foreach ($a in $existing) {
$tgt = $a.target
if (-not $tgt) { continue }
$key = ([string]$a.intent) + '|' + ([string]$tgt.'@odata.type') + '|' + ([string]$tgt.groupId)
$seen[$key] = $true
$assignments += @{
'@odata.type' = '#microsoft.graph.mobileAppAssignment'
intent = [string]$a.intent
target = $tgt # inkl. evtl. Filter -> unveraendert beibehalten
settings = $a.settings # bestehende Assignment-Settings bewahren
}
}
foreach ($t in $newTargets) {
$key = $Intent + '|' + [string]$t['@odata.type'] + '|' + [string]$t['groupId']
if ($seen.ContainsKey($key)) { continue } # bereits (mit diesem Intent) zugewiesen
$seen[$key] = $true
$assignments += @{
'@odata.type' = '#microsoft.graph.mobileAppAssignment'
intent = $Intent intent = $Intent
target = $target target = $t
settings = $null settings = $null
}) }
} }
$body = @{ mobileAppAssignments = @($assignments) }
Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body
} }
@@ -827,16 +879,15 @@ function Remove-GraphMobileApp {
} }
} }
# Excluded App-Typen aus dem Original-Script # Ausgeschlossene App-Typen: iOS/Android bleiben ausgeblendet (dieses Tool ist auf
# Windows + macOS ausgelegt). macOS-Typen sind bewusst NICHT mehr ausgeschlossen,
# damit Mac-Apps in Liste/Suche erscheinen.
$script:ExcludedAppTypes = @( $script:ExcludedAppTypes = @(
'#microsoft.graph.iosLobApp', '#microsoft.graph.iosStoreApp', '#microsoft.graph.iosVppApp', '#microsoft.graph.iosLobApp', '#microsoft.graph.iosStoreApp', '#microsoft.graph.iosVppApp',
'#microsoft.graph.managedIOSLobApp', '#microsoft.graph.managedIOSStoreApp', '#microsoft.graph.managedIOSLobApp', '#microsoft.graph.managedIOSStoreApp',
'#microsoft.graph.androidLobApp', '#microsoft.graph.androidStoreApp', '#microsoft.graph.androidForWorkApp', '#microsoft.graph.androidLobApp', '#microsoft.graph.androidStoreApp', '#microsoft.graph.androidForWorkApp',
'#microsoft.graph.androidManagedStoreApp', '#microsoft.graph.androidManagedStoreWebApp', '#microsoft.graph.androidManagedStoreApp', '#microsoft.graph.androidManagedStoreWebApp',
'#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp', '#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp'
'#microsoft.graph.macOSDmgApp', '#microsoft.graph.macOSLobApp', '#microsoft.graph.macOSMicrosoftDefenderApp',
'#microsoft.graph.macOSMicrosoftEdgeApp', '#microsoft.graph.macOSOfficeSuiteApp', '#microsoft.graph.macOSPkgApp',
'#microsoft.graph.macOsVppApp'
) )
function Test-AppTypeAllowed { function Test-AppTypeAllowed {
@@ -858,6 +909,13 @@ function ConvertTo-AppFriendlyType {
'#microsoft.graph.windowsAppX' { 'APPX' } '#microsoft.graph.windowsAppX' { 'APPX' }
'#microsoft.graph.windowsUniversalAppX' { 'APPX' } '#microsoft.graph.windowsUniversalAppX' { 'APPX' }
'#microsoft.graph.windowsMobileMSI' { 'MSI' } '#microsoft.graph.windowsMobileMSI' { 'MSI' }
'#microsoft.graph.macOSDmgApp' { 'macOS DMG' }
'#microsoft.graph.macOSPkgApp' { 'macOS PKG' }
'#microsoft.graph.macOSLobApp' { 'macOS LOB' }
'#microsoft.graph.macOSMicrosoftDefenderApp' { 'macOS Defender' }
'#microsoft.graph.macOSMicrosoftEdgeApp' { 'macOS Edge' }
'#microsoft.graph.macOSOfficeSuiteApp' { 'macOS M365' }
'#microsoft.graph.macOsVppApp' { 'macOS VPP' }
default { default {
($Type -replace '#microsoft\.graph\.','') ($Type -replace '#microsoft\.graph\.','')
} }
+7
View File
@@ -74,6 +74,13 @@ function Get-DefaultSettings {
# Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis. # Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis.
groupNames = @() groupNames = @()
} }
policyBackup = [pscustomobject]@{
# Lokaler Git-Repo-Ordner fuer Policy-Snapshots. Leer = Funktion aus.
gitRepoPath = ""
# Nach dem Commit automatisch 'git push' ausfuehren (nutzt den
# vorhandenen Git-Credential-Helper; kein Token in der App).
push = $false
}
userSearch = [pscustomobject]@{ userSearch = [pscustomobject]@{
# In welchen Feldern bei der Benutzersuche gesucht wird. # In welchen Feldern bei der Benutzersuche gesucht wird.
# Erlaubt: displayName, userPrincipalName, mail, department. # Erlaubt: displayName, userPrincipalName, mail, department.
+384
View File
@@ -0,0 +1,384 @@
# Geraete-Offboarding: ein Geraet ueber Intune, Autopilot und Entra ID hinweg
# entfernen (Decommissioning). Vor dem Loeschen koennen BitLocker-/FileVault-Keys
# und LAPS-Passwoerter ausgelesen werden, damit sie nicht verloren gehen.
#
# Portiert aus dem "Device Offboarding Manager" (Ugur Koc, MIT-Lizenz) — dort
# WPF, hier als Web-Endpoints in den Intune Manager integriert.
#
# Loeschen ist destruktiv -> im Read-Only-Modus gesperrt, harte Bestaetigung im UI.
# Generischer Microsoft-Graph-$batch-Helper. Nimmt Sub-Requests
# ( @{ id; method; url; body?; headers? } ) und liefert eine Map id -> Response.
function Invoke-GraphBatch {
param([Parameter(Mandatory=$true)][object[]]$Requests)
$map = @{}
for ($i = 0; $i -lt $Requests.Count; $i += 20) {
$chunk = $Requests[$i .. [Math]::Min($i + 19, $Requests.Count - 1)]
$body = @{ requests = @($chunk) } | ConvertTo-Json -Depth 10 -Compress
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
foreach ($r in @($resp.responses)) { $map[[string]$r.id] = $r }
}
return $map
}
# Graph-Fehlermeldung aus einer Exception ziehen (Body statt generischer Text).
function Get-OffboardGraphErr {
param($ErrorRecord)
$m = $ErrorRecord.Exception.Message
try { if ($ErrorRecord.ErrorDetails.Message) { $m = $ErrorRecord.ErrorDetails.Message } } catch {}
return $m
}
# Geraete suchen und ueber die drei Dienste hinweg aufloesen.
# Query: query=<text>, type=name|serial
function Search-OffboardDevicesEndpoint {
param([hashtable]$Query)
$err = Test-Connected
if ($err) { return $err }
$q = [string]$Query['query']
$type = [string]$Query['type']
if ([string]::IsNullOrWhiteSpace($q)) { return @{ __status = 400; error = 'Suchbegriff fehlt' } }
$qEsc = $q -replace "'", "''"
# managedDevices unterstuetzt startswith(deviceName) bzw. serialNumber eq.
$filter = if ($type -eq 'serial') { "serialNumber eq '$qEsc'" } else { "startswith(deviceName,'$qEsc')" }
$sel = 'id,deviceName,serialNumber,operatingSystem,osVersion,userPrincipalName,lastSyncDateTime,azureADDeviceId,managedDeviceOwnerType,managementAgent'
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=$([Uri]::EscapeDataString($filter))&`$select=$sel&`$top=50&`$orderby=deviceName"
try {
$intune = @(Get-GraphPaged -Uri $uri)
} catch {
$m = Get-OffboardGraphErr $_
if ($m -match '403|Forbidden') {
return @{ __status = 403; error = 'Keine Berechtigung fuer die Geraete-Suche. Scope "DeviceManagementManagedDevices.Read.All" muss in den Read/Write-Scopes stehen UND per Admin-Consent zugestimmt sein (siehe docs/App-Registration.md). Nach dem Ergaenzen: ab- und neu anmelden.' }
}
return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" }
}
$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 = @()
$idx = 0
foreach ($d in $intune) {
$aad = [string]$d.azureADDeviceId
$ser = [string]$d.serialNumber
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" }
}
if ($ser) {
$sf = [Uri]::EscapeDataString("contains(serialNumber,'$($ser -replace "'","''")')")
$reqs += @{ id = "a$idx"; method = 'GET'; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$sf&`$top=1" }
}
$idx++
}
$batch = if ($reqs.Count -gt 0) { Invoke-GraphBatch -Requests $reqs } else { @{} }
$items = @()
$idx = 0
foreach ($d in $intune) {
$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) {
$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
operatingSystem = [string]$d.operatingSystem
osVersion = [string]$d.osVersion
primaryUser = [string]$d.userPrincipalName
lastSync = $d.lastSyncDateTime
ownership = [string]$d.managedDeviceOwnerType
coManaged = ([string]$d.managementAgent -match 'configurationManager')
intuneDeviceId = [string]$d.id
azureADDeviceId = [string]$d.azureADDeviceId
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)
}
# 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:
# { intuneDeviceId, azureADDeviceId, operatingSystem }
function Get-OffboardKeysEndpoint {
param($Body)
$err = Test-Connected
if ($err) { return $err }
$intuneId = [string](Get-PolicyProp $Body 'intuneDeviceId')
$aad = [string](Get-PolicyProp $Body 'azureADDeviceId')
$os = [string](Get-PolicyProp $Body 'operatingSystem')
$bitlocker = @()
$fileVault = $null
$laps = $null
$notes = @()
if ($os -eq 'Windows' -and $aad) {
try {
$keyIds = @(Get-GraphPaged -Uri "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$aad'")
foreach ($k in $keyIds) {
try {
$kd = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys/$($k.id)?`$select=key,volumeType" -Method GET
if ($kd.key) { $bitlocker += @{ volumeType = [string]$kd.volumeType; key = [string]$kd.key } }
} catch {}
}
if ($bitlocker.Count -eq 0) { $notes += 'Kein BitLocker-Key gefunden.' }
} catch {
$m = Get-OffboardGraphErr $_
$notes += if ($m -match '403') { 'BitLocker: Zugriff verweigert (BitlockerKey.Read.All noetig).' } else { "BitLocker: $m" }
}
}
elseif ($os -eq 'macOS' -and $intuneId) {
try {
$fv = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$intuneId')/getFileVaultKey" -Method GET
if ($fv.value) { $fileVault = [string]$fv.value } else { $notes += 'Kein FileVault-Key gefunden.' }
} catch { $notes += "FileVault: $(Get-OffboardGraphErr $_)" }
}
# LAPS (jede Plattform, ueber die Entra-Device-Id).
if ($aad) {
try {
$resp = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$aad`?`$select=credentials" -Method GET
$creds = @($resp.credentials)
if ($creds.Count -gt 0) {
$latest = $creds | Sort-Object -Property backupDateTime -Descending | Select-Object -First 1
$pw = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$latest.passwordBase64))
$laps = @{ account = [string]$latest.accountName; password = $pw }
}
} catch {
$m = Get-OffboardGraphErr $_
if ($m -notmatch '404') { $notes += "LAPS: $m" }
}
}
return @{ ok = $true; keys = @{ bitlocker = @($bitlocker); fileVault = $fileVault; laps = $laps; notes = @($notes) } }
}
# Offboarding ausfuehren. Body:
# devices = [ { deviceName, intuneDeviceId, entraObjectId, autopilotId } ]
# services = { entra: 'delete'|'disable'|'none', intune: bool, autopilot: bool }
function Invoke-OffboardExecuteEndpoint {
param($Body)
$err = Test-Connected
if ($err) { return $err }
if ($script:State.ReadOnly) { return @{ __status = 403; error = 'Read-Only-Modus: Offboarding ist deaktiviert.' } }
$devices = @(Get-PolicyProp $Body 'devices') | Where-Object { $_ }
$svc = Get-PolicyProp $Body 'services'
if (@($devices).Count -eq 0) { return @{ __status = 400; error = 'Keine Geraete uebergeben' } }
$entraAction = [string](Get-PolicyProp $svc 'entra')
$doIntune = [bool](Get-PolicyProp $svc 'intune')
$doAutopilot = [bool](Get-PolicyProp $svc 'autopilot')
$reqs = @()
$meta = @{}
$n = 0
foreach ($d in $devices) {
$name = [string](Get-PolicyProp $d 'deviceName')
$iid = [string](Get-PolicyProp $d 'intuneDeviceId')
$eid = [string](Get-PolicyProp $d 'entraObjectId')
$aid = [string](Get-PolicyProp $d 'autopilotId')
if ($entraAction -eq 'delete' -and $eid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/devices/$eid" }
$meta[$rid] = @{ name = $name; svc = 'Entra ID'; action = 'geloescht' }; $n++
} elseif ($entraAction -eq 'disable' -and $eid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'PATCH'; url = "/devices/$eid"; body = @{ accountEnabled = $false }; headers = @{ 'Content-Type' = 'application/json' } }
$meta[$rid] = @{ name = $name; svc = 'Entra ID'; action = 'deaktiviert' }; $n++
}
if ($doIntune -and $iid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/deviceManagement/managedDevices/$iid" }
$meta[$rid] = @{ name = $name; svc = 'Intune'; action = 'geloescht' }; $n++
}
if ($doAutopilot -and $aid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/deviceManagement/windowsAutopilotDeviceIdentities/$aid" }
$meta[$rid] = @{ name = $name; svc = 'Autopilot'; action = 'geloescht' }; $n++
}
}
if ($reqs.Count -eq 0) { return @{ __status = 400; error = 'Keine ausfuehrbaren Aktionen (fehlende IDs oder nichts ausgewaehlt).' } }
Write-Host "[OFFBOARD] Fuehre $($reqs.Count) Aktion(en) fuer $(@($devices).Count) Geraet(e) aus..." -ForegroundColor Yellow
$batch = Invoke-GraphBatch -Requests $reqs
$results = @()
foreach ($rid in ($meta.Keys | Sort-Object { [int]($_ -replace '\D','') })) {
$m = $meta[$rid]
$r = $batch[$rid]
$status = if ($r) { [int]$r.status } else { 0 }
$ok = $status -in @(200, 204)
$errMsg = ''
if (-not $ok) {
$code = ''
try { $code = [string]$r.body.error.code } catch {}
if ($status -eq 403 -and $code -match 'multipleAdminApproval|protectedOperation') {
$errMsg = 'Erfordert Multi-Admin-Approval'
} elseif ($status -eq 403) {
$errMsg = "403 - fehlende Rolle/Berechtigung fuer $($m.svc)"
} elseif ($status -eq 0) {
$errMsg = 'Keine Antwort'
} else {
$errMsg = "HTTP $status" + $(if ($code) { " ($code)" } else { '' })
}
}
$results += [pscustomobject]@{
deviceName = $m.name; service = $m.svc; action = $m.action
success = $ok; error = $errMsg
}
}
$okCount = @($results | Where-Object { $_.success }).Count
$errCount = $results.Count - $okCount
Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray
# 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 }
}
+682 -14
View File
@@ -1,7 +1,7 @@
# Import/Export von Intune-Policies. # Import/Export von Intune-Policies.
# Unterstuetzte Typen: Compliance Policies, Configuration Profiles (Templates) # Unterstuetzte Typen: Compliance Policies, Configuration Profiles (Templates),
# und Settings Catalog. Nutzt die bestehende Microsoft-Graph-Verbindung # Settings Catalog und Administrative Vorlagen (ADMX / groupPolicyConfigurations).
# (Connect-MgGraph via Api.ps1). # Nutzt die bestehende Microsoft-Graph-Verbindung (Connect-MgGraph via Api.ps1).
# #
# Zwei Export-Wege, beide aus derselben Aktion: # Zwei Export-Wege, beide aus derselben Aktion:
# 1. Browser-Download — der Endpoint liefert die Export-Objekte im Response, # 1. Browser-Download — der Endpoint liefert die Export-Objekte im Response,
@@ -73,6 +73,20 @@ function Get-PolicyTypeConfig {
Label = 'Settings Catalog' Label = 'Settings Catalog'
} }
} }
'administrativetemplate' {
return @{
Key = 'administrativetemplate'
Collection = 'groupPolicyConfigurations'
NameField = 'displayName'
# Sonderfall: die konfigurierten Werte liegen nicht inline in der
# Policy, sondern in der definitionValues-Subcollection. Deshalb
# kein simples $expand -> eigene Behandlung in Get-GraphPolicyDetail.
ExportExpand = $null
ExportType = 'AdministrativeTemplate'
FilePrefix = 'AdminTemplate'
Label = 'Administrative Vorlage'
}
}
default { return $null } default { return $null }
} }
} }
@@ -80,7 +94,7 @@ function Get-PolicyTypeConfig {
# ExportType (aus Datei/Envelope) -> Typ-Config. Reverse-Lookup fuer den Import. # ExportType (aus Datei/Envelope) -> Typ-Config. Reverse-Lookup fuer den Import.
function Get-PolicyTypeConfigByExportType { function Get-PolicyTypeConfigByExportType {
param([string]$ExportType) param([string]$ExportType)
foreach ($key in @('compliance','configuration','settingscatalog')) { foreach ($key in @('compliance','configuration','settingscatalog','administrativetemplate')) {
$cfg = Get-PolicyTypeConfig $key $cfg = Get-PolicyTypeConfig $key
if ($cfg.ExportType -eq $ExportType) { return $cfg } if ($cfg.ExportType -eq $ExportType) { return $cfg }
} }
@@ -105,6 +119,15 @@ function Get-PolicyProp {
return $null return $null
} }
# Property setzen — Hashtable ODER PSCustomObject (fuer Import-Umbenennung).
function Set-PolicyProp {
param($Obj, [string]$Name, $Value)
if ($null -eq $Obj) { return }
if ($Obj -is [System.Collections.IDictionary]) { $Obj[$Name] = $Value; return }
if ($Obj.PSObject.Properties[$Name]) { $Obj.$Name = $Value }
else { $Obj | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force }
}
# Grobe Plattform-Bezeichnung fuer die Listenanzeige. # Grobe Plattform-Bezeichnung fuer die Listenanzeige.
function Get-PolicyPlatformLabel { function Get-PolicyPlatformLabel {
param($Raw, [string]$Type) param($Raw, [string]$Type)
@@ -112,6 +135,8 @@ function Get-PolicyPlatformLabel {
$p = Get-PolicyProp $Raw 'platforms' $p = Get-PolicyProp $Raw 'platforms'
return [string]$p return [string]$p
} }
# Administrative Vorlagen (groupPolicyConfigurations) sind reine Windows-Policies.
if (([string]$Type).ToLower() -eq 'administrativetemplate') { return 'Windows' }
$t = [string](Get-PolicyProp $Raw '@odata.type') $t = [string](Get-PolicyProp $Raw '@odata.type')
switch -Regex ($t) { switch -Regex ($t) {
'windows' { return 'Windows' } 'windows' { return 'Windows' }
@@ -158,9 +183,23 @@ function Get-GraphPolicyDetail {
) )
$cfg = Get-PolicyTypeConfig $Type $cfg = Get-PolicyTypeConfig $Type
if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" } if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" }
# Administrative Vorlagen: Basis-Objekt holen und die konfigurierten Werte
# (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).
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)"
$dvs = @(Get-GraphPaged -Uri $dvUri -AsRawJson)
if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs }
else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force }
return $base
}
$uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id" $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id"
if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" } if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" }
return Invoke-MgGraphRequestRetry -Uri $uri -Method GET return Invoke-MgGraphRequestRetry -Uri $uri -Method GET -AsRawJson
} }
# PSCustomObject/Hashtable -> bereinigte Hashtable ohne Read-Only-Felder. # PSCustomObject/Hashtable -> bereinigte Hashtable ohne Read-Only-Felder.
@@ -183,6 +222,107 @@ function ConvertTo-ImportBody {
return $body return $body
} }
# Settings-Catalog-Payloads haben verschachtelte Properties, die laut Graph-
# Schema Arrays sein MUESSEN (settings, children, *SettingCollectionValue, values).
# Ein JSON-Roundtrip unter Windows PowerShell 5.1 entpackt Ein-Element-Arrays zu
# Einzelobjekten -> Graph antwortet mit 400 "... does not match schema". Diese
# Funktion baut den Baum rekursiv neu auf und packt betroffene Felder wieder in
# Arrays. Idempotent: bereits korrekte Arrays bleiben unveraendert.
function Repair-SettingsCatalogArrays {
param($Node)
$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
# ('Property settingValueTemplateReference ... does not match schema'). Der bloße
# String ist eindeutig korrupt und nicht rekonstruierbar -> auf null setzen; die
# (optionale) Template-Bindung entfaellt, die eigentlichen Werte bleiben erhalten.
$refKeys = @('settingInstanceTemplateReference','settingValueTemplateReference')
# Array-Property normalisieren — WICHTIG inline (Zuweisung, KEIN Funktions-
# Return): ein leeres Array aus einer Funktion zurueckzugeben entpackt PS zu
# $null, was zu 'children: {}' statt '[]' fuehrt. Als Zuweisung bleibt @() ein @().
# $null -> @() (Graph-Schema: Collections sind Nullable=False)
# Einzelobjekt -> @(obj) (Ein-Element-Array wurde vom Roundtrip skalarisiert)
# leere/Whitespace-STRING-Elemente entfernen: ein PS-JSON-Roundtrip macht aus
# einer leeren Collection [] teils ""/[""] -> Graph lehnt das als
# 'Property children ... does not match schema' ab. Diese Keys tragen nie bare
# Strings -> gefahrlos filtern; wird die Collection dadurch leer, bleibt [].
if ($Node -is [System.Collections.IDictionary]) {
$out = [ordered]@{}
foreach ($k in @($Node.Keys)) {
$fixed = Repair-SettingsCatalogArrays $Node[$k]
if ($k -in $arrayKeys) {
if ($null -eq $fixed) { $fixed = @() }
elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) }
$fixed = @($fixed | Where-Object { -not (($_ -is [string]) -and [string]::IsNullOrWhiteSpace($_)) })
}
elseif ($k -in $refKeys -and ($fixed -is [string])) { $fixed = $null }
$out[$k] = $fixed
}
return $out
}
if ($Node -is [System.Management.Automation.PSCustomObject]) {
$out = [ordered]@{}
foreach ($p in $Node.PSObject.Properties) {
$fixed = Repair-SettingsCatalogArrays $p.Value
if ($p.Name -in $arrayKeys) {
if ($null -eq $fixed) { $fixed = @() }
elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) }
$fixed = @($fixed | Where-Object { -not (($_ -is [string]) -and [string]::IsNullOrWhiteSpace($_)) })
}
elseif ($p.Name -in $refKeys -and ($fixed -is [string])) { $fixed = $null }
$out[$p.Name] = $fixed
}
return $out
}
if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) {
# Kein Komma-Operator: ein Ein-Element-Array wird beim Return zwar zum
# Skalar entpackt, aber jede Array-Property wird vom Parent ohnehin wieder
# in @(...) gewrappt. Ein fuehrendes ',' wuerde das Top-Level-settings-
# Array faelschlich in ein Extra-Array verschachteln.
return @($Node | ForEach-Object { Repair-SettingsCatalogArrays $_ })
}
return $Node
}
# Entfernt rekursiv alle Properties mit $null-Wert. Configuration Profiles
# exportieren nicht genutzte Collection-Properties als null; beim POST lehnt Graph
# null fuer 'Collection(...)[Nullable=False]' ab (400 ModelValidationFailure, z.B.
# 'defenderAdditionalGuardedFolders'). Weggelassene Properties belegt Graph mit
# Defaults -> sicheres Strippen. Array-Typen werden am Parent wieder in @()
# gewrappt, damit ein Ein-Element-Array beim Return nicht zum Skalar entpackt wird.
function Remove-PolicyNullProps {
param($Node)
if ($Node -is [System.Collections.IDictionary]) {
$out = @{}
foreach ($k in @($Node.Keys)) {
$v = $Node[$k]
if ($null -eq $v) { continue }
$fixed = Remove-PolicyNullProps $v
if (($v -is [System.Collections.IEnumerable]) -and -not ($v -is [string]) -and -not ($v -is [System.Collections.IDictionary])) {
$out[$k] = @($fixed)
} else { $out[$k] = $fixed }
}
return $out
}
if ($Node -is [System.Management.Automation.PSCustomObject]) {
$out = @{}
foreach ($p in $Node.PSObject.Properties) {
if ($null -eq $p.Value) { continue }
$fixed = Remove-PolicyNullProps $p.Value
if (($p.Value -is [System.Collections.IEnumerable]) -and -not ($p.Value -is [string]) -and -not ($p.Value -is [System.Collections.IDictionary])) {
$out[$p.Name] = @($fixed)
} else { $out[$p.Name] = $fixed }
}
return $out
}
if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) {
return @($Node | ForEach-Object { Remove-PolicyNullProps $_ })
}
return $Node
}
function New-DefaultComplianceScheduledActions { function New-DefaultComplianceScheduledActions {
# Compliance Policies verlangen beim Anlegen mindestens einen # Compliance Policies verlangen beim Anlegen mindestens einen
# scheduledActionsForRule-Block, sonst antwortet Graph mit 400. # scheduledActionsForRule-Block, sonst antwortet Graph mit 400.
@@ -236,6 +376,8 @@ function Import-GraphConfigurationProfile {
if (-not $body['@odata.type']) { if (-not $body['@odata.type']) {
throw 'Configuration Profile benoetigt @odata.type fuer den Import.' throw 'Configuration Profile benoetigt @odata.type fuer den Import.'
} }
# null-Properties strippen: Graph lehnt null fuer nicht-nullbare Collections ab.
$body = Remove-PolicyNullProps $body
$json = $body | ConvertTo-Json -Depth 50 $json = $body | ConvertTo-Json -Depth 50
return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' -Method POST -Body $json -ContentType 'application/json' return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' -Method POST -Body $json -ContentType 'application/json'
} }
@@ -247,18 +389,117 @@ function Import-GraphSettingsCatalogPolicy {
throw 'Settings-Catalog-Policy benoetigt ein "name"-Feld fuer den Import.' throw 'Settings-Catalog-Policy benoetigt ein "name"-Feld fuer den Import.'
} }
# 'settings' MUSS mitgeschickt werden — kommt aus dem $expand=settings-Export. # 'settings' MUSS mitgeschickt werden — kommt aus dem $expand=settings-Export.
if (-not $body.ContainsKey('settings')) { $body['settings'] = @() } # Zusaetzlich Array-Properties reparieren (PS-5.1-Roundtrip-Schaden), sonst
# 400 "Property children ... does not match schema".
if ($body.ContainsKey('settings') -and $null -ne $body['settings']) {
$body['settings'] = @(Repair-SettingsCatalogArrays $body['settings'])
# Beim GET liefert Graph die Setting-Wrapper ohne '@odata.type' und mit
# read-only 'id'. Der POST verlangt aber den Wrapper-Typ; die 'id' muss
# weg -> sonst 400 "Property settings ... does not match schema".
foreach ($s in $body['settings']) {
if ($s -is [System.Collections.IDictionary]) {
if ($s.Contains('id')) { [void]$s.Remove('id') }
if (-not $s.Contains('@odata.type')) {
$s['@odata.type'] = '#microsoft.graph.deviceManagementConfigurationSetting'
}
}
}
} else {
$body['settings'] = @()
}
$json = $body | ConvertTo-Json -Depth 50 $json = $body | ConvertTo-Json -Depth 50
# Korruptions-Check: enthaelt der Payload noch stringifizierte .NET-Objekte
# ("System.Collections.Hashtable" / "System.Object[]"), stammt die Quelldatei aus
# einem alten Export mit zu geringer ConvertTo-Json-Tiefe. *TemplateReference
# (optionale Metadaten) wurde oben bereits gerettet; verbleibende Marker sitzen in
# WERT-tragenden Feldern (z.B. groupSettingCollectionValue) -> echte Konfiguration
# ist verloren und nicht rekonstruierbar. Klar abbrechen statt kaputt zu importieren.
if ($json -match 'System\.Collections\.Hashtable|System\.Object\[\]') {
throw 'Quelldatei beschaedigt: Teile der Konfiguration wurden von einem alten Export (zu geringe JSON-Tiefe) zu ".ToString()" verstuemmelt und sind nicht wiederherstellbar. Bitte die Policy neu aus Graph exportieren und die frische Datei importieren.'
}
return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json'
} }
function Import-GraphAdministrativeTemplate {
param([Parameter(Mandatory=$true)]$Policy)
$displayName = [string](Get-PolicyProp $Policy 'displayName')
if (-not $displayName) { throw 'Administrative Vorlage benoetigt "displayName" fuer den Import.' }
$defRoot = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions'
$cfgRoot = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations'
# 1) Leere Konfigurations-Huelle anlegen (definitionValues folgen einzeln).
$shell = @{
displayName = $displayName
description = [string](Get-PolicyProp $Policy 'description')
roleScopeTagIds = @('0')
}
$created = Invoke-MgGraphRequestRetry -Uri $cfgRoot -Method POST -Body ($shell | ConvertTo-Json -Depth 10) -ContentType 'application/json'
$newId = [string](Get-PolicyProp $created 'id')
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.
$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'
continue
}
$presVals = @()
foreach ($pv in @(Get-PolicyProp $dv 'presentationValues')) {
if (-not $pv) { continue }
$pres = Get-PolicyProp $pv 'presentation'
$presId = [string](Get-PolicyProp $pres 'id')
$entry = [ordered]@{
'@odata.type' = [string](Get-PolicyProp $pv '@odata.type')
'presentation@odata.bind' = "$defRoot('$defId')/presentations('$presId')"
}
# Je nach Presentation-Typ traegt der Wert in 'value' ODER 'values'.
foreach ($vk in @('value','values')) {
$vv = Get-PolicyProp $pv $vk
if ($null -ne $vv) { $entry[$vk] = $vv }
}
$presVals += $entry
}
$body = [ordered]@{
enabled = [bool](Get-PolicyProp $dv 'enabled')
'definition@odata.bind' = "$defRoot('$defId')"
presentationValues = @($presVals)
}
try {
Invoke-MgGraphRequestRetry -Uri "$cfgRoot/$newId/definitionValues" -Method POST -Body ($body | ConvertTo-Json -Depth 50) -ContentType 'application/json' | Out-Null
} 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"
}
}
if ($errors.Count -gt 0) {
throw ("Huelle angelegt (Id $newId), aber $($errors.Count) Einstellung(en) fehlgeschlagen: " + ($errors -join ' | '))
}
return $created
}
# Dispatcht anhand des ExportType auf den passenden Import. # Dispatcht anhand des ExportType auf den passenden Import.
function Import-GraphPolicyByExportType { function Import-GraphPolicyByExportType {
param([string]$ExportType, $Policy) param([string]$ExportType, $Policy)
switch ($ExportType) { switch ($ExportType) {
'CompliancePolicy' { return Import-GraphCompliancePolicy -Policy $Policy } 'CompliancePolicy' { return Import-GraphCompliancePolicy -Policy $Policy }
'ConfigurationProfile' { return Import-GraphConfigurationProfile -Policy $Policy } 'ConfigurationProfile' { return Import-GraphConfigurationProfile -Policy $Policy }
'SettingsCatalog' { return Import-GraphSettingsCatalogPolicy -Policy $Policy } 'SettingsCatalog' { return Import-GraphSettingsCatalogPolicy -Policy $Policy }
'AdministrativeTemplate' { return Import-GraphAdministrativeTemplate -Policy $Policy }
default { throw "Unbekannter exportType: $ExportType" } default { throw "Unbekannter exportType: $ExportType" }
} }
} }
@@ -288,6 +529,11 @@ function Get-SettingsCatalogPoliciesEndpoint {
return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'settingscatalog') } return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'settingscatalog') }
} }
function Get-AdministrativeTemplatesEndpoint {
try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } }
return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'administrativetemplate') }
}
# Export: liefert die Export-Objekte im Response (Browser-Download) UND legt sie # Export: liefert die Export-Objekte im Response (Browser-Download) UND legt sie
# zusaetzlich als JSON im Server-Archiv ab. Body: { type, ids }. # zusaetzlich als JSON im Server-Archiv ab. Body: { type, ids }.
function Export-PoliciesEndpoint { function Export-PoliciesEndpoint {
@@ -368,10 +614,14 @@ function Import-PoliciesEndpoint {
param($Body) param($Body)
try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } }
$uploaded = @(Get-PolicyProp $Body 'policies') # @(Get-PolicyProp ...) auf ein fehlendes Feld liefert $null -> @($null) hat
$fileNames = @(Get-PolicyProp $Body 'fileNames') # Count 1 (ein Null-Element), kein leeres Array. Ohne Filter wuerde die
# Archiv-Schleife einmal mit $fileName = $null laufen und auf das Verzeichnis
# selbst zugreifen (DirectoryNotFoundException). Daher Null/Leer rausfiltern.
$uploaded = @(Get-PolicyProp $Body 'policies') | Where-Object { $_ }
$fileNames = @(Get-PolicyProp $Body 'fileNames') | Where-Object { $_ }
if ($uploaded.Count -eq 0 -and $fileNames.Count -eq 0) { if (@($uploaded).Count -eq 0 -and @($fileNames).Count -eq 0) {
return @{ __status = 400; error = 'Keine Policies zum Importieren uebergeben' } return @{ __status = 400; error = 'Keine Policies zum Importieren uebergeben' }
} }
@@ -380,7 +630,10 @@ function Import-PoliciesEndpoint {
# 1) Hochgeladene Envelopes # 1) Hochgeladene Envelopes
foreach ($env in $uploaded) { foreach ($env in $uploaded) {
if (-not $env) { continue } if (-not $env) { continue }
# Envelope-Formate akzeptieren beide Typ-Felder: 'exportType' (Export-
# Download) und 'policyType' (Git-Snapshot).
$exportType = [string](Get-PolicyProp $env 'exportType') $exportType = [string](Get-PolicyProp $env 'exportType')
if (-not $exportType) { $exportType = [string](Get-PolicyProp $env 'policyType') }
$policy = Get-PolicyProp $env 'policy' $policy = Get-PolicyProp $env 'policy'
$policyName = Get-PolicyProp $env 'policyName' $policyName = Get-PolicyProp $env 'policyName'
if (-not $policyName) { $policyName = Get-PolicyDisplayName $policy } if (-not $policyName) { $policyName = Get-PolicyDisplayName $policy }
@@ -388,6 +641,14 @@ function Import-PoliciesEndpoint {
$results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' } $results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' }
continue continue
} }
# Optionale Umbenennung: das richtige Namensfeld je Typ setzen.
$newName = [string](Get-PolicyProp $env 'newName')
if ($newName -and $newName.Trim()) {
$cfg = Get-PolicyTypeConfigByExportType $exportType
$nameField = if ($cfg) { $cfg.NameField } else { 'displayName' }
Set-PolicyProp $policy $nameField $newName.Trim()
$policyName = $newName.Trim()
}
try { try {
$imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy
$results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') }
@@ -401,9 +662,10 @@ function Import-PoliciesEndpoint {
# 2) Dateien aus dem Server-Archiv # 2) Dateien aus dem Server-Archiv
$exportDir = Get-PolicyExportDir $exportDir = Get-PolicyExportDir
foreach ($fileName in $fileNames) { foreach ($fileName in $fileNames) {
$safe = ($fileName -replace '[\\/]', '') if ([string]::IsNullOrWhiteSpace([string]$fileName)) { continue }
$safe = ([string]$fileName -replace '[\\/]', '')
$filePath = Join-Path $exportDir $safe $filePath = Join-Path $exportDir $safe
if (-not (Test-Path $filePath)) { if ([string]::IsNullOrWhiteSpace($safe) -or -not (Test-Path $filePath -PathType Leaf)) {
$results += @{ fileName = $fileName; success = $false; error = 'Datei nicht gefunden' } $results += @{ fileName = $fileName; success = $false; error = 'Datei nicht gefunden' }
continue continue
} }
@@ -411,6 +673,7 @@ function Import-PoliciesEndpoint {
try { try {
$content = Get-Content $filePath -Raw | ConvertFrom-Json $content = Get-Content $filePath -Raw | ConvertFrom-Json
$exportType = [string](Get-PolicyProp $content 'exportType') $exportType = [string](Get-PolicyProp $content 'exportType')
if (-not $exportType) { $exportType = [string](Get-PolicyProp $content 'policyType') }
$policy = Get-PolicyProp $content 'policy' $policy = Get-PolicyProp $content 'policy'
$policyName = Get-PolicyDisplayName $policy $policyName = Get-PolicyDisplayName $policy
$imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy
@@ -425,3 +688,408 @@ function Import-PoliciesEndpoint {
$ok = @($results | Where-Object { $_.success }).Count $ok = @($results | Where-Object { $_.success }).Count
return @{ ok = $true; importedCount = $ok; results = @($results) } return @{ ok = $true; importedCount = $ok; results = @($results) }
} }
# Weist importierten Policies Gruppen zu (Include + Exclude). Body:
# items = [ { exportType, id, policyName, include:[groupId], exclude:[groupId] } ]
# Nutzt die typ-spezifische /assign-Action. Zuweisungen sind pro Policy.
function Invoke-PolicyAssignEndpoint {
param($Body)
try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } }
$items = @(Get-PolicyProp $Body 'items') | Where-Object { $_ }
if (@($items).Count -eq 0) { return @{ __status = 400; error = 'Keine Zuweisungen uebergeben' } }
$results = @()
foreach ($it in $items) {
$exportType = [string](Get-PolicyProp $it 'exportType')
$id = [string](Get-PolicyProp $it 'id')
$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 -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; mode = $mode; includeCount = @($include).Count; excludeCount = @($exclude).Count }
} catch {
$m = $_.Exception.Message
try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {}
$results += @{ id = $id; policyName = $name; success = $false; error = $m }
}
}
$ok = @($results | Where-Object { $_.success -and -not $_.skipped }).Count
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)
# ============================================================
# Rekursiv nach Schluesseln sortieren -> deterministische JSON-Ausgabe, damit
# unveraenderte Policies keine Diff-Noise durch wechselnde Key-Reihenfolge
# erzeugen (Invoke-MgGraphRequest liefert ungeordnete Hashtables).
function ConvertTo-StableObject {
param($InputObject)
if ($InputObject -is [System.Collections.IDictionary]) {
$ordered = [ordered]@{}
foreach ($k in ($InputObject.Keys | Sort-Object)) {
$ordered[$k] = ConvertTo-StableObject $InputObject[$k]
}
return $ordered
}
if (($InputObject -is [System.Collections.IEnumerable]) -and -not ($InputObject -is [string])) {
# Array-Reihenfolge bleibt erhalten (ist bei Policies bedeutungstragend).
return @($InputObject | ForEach-Object { ConvertTo-StableObject $_ })
}
return $InputObject
}
# git muss nicht im PATH des Server-Prozesses liegen (haeufig, wenn der Server vor
# der Git-Installation gestartet wurde oder mit eingefrorener Umgebung laeuft).
# Erst PATH probieren, dann bekannte Installationsorte. Ergebnis wird gecacht.
function Get-GitExe {
if ($script:GitExe -and (Test-Path $script:GitExe)) { return $script:GitExe }
$cmd = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cmd) { $script:GitExe = $cmd.Source; return $script:GitExe }
$cands = @()
if ($env:ProgramFiles) { $cands += (Join-Path $env:ProgramFiles 'Git\cmd\git.exe') }
if (${env:ProgramFiles(x86)}) { $cands += (Join-Path ${env:ProgramFiles(x86)} 'Git\cmd\git.exe') }
if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA 'Programs\Git\cmd\git.exe') }
if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\git.exe') }
foreach ($c in $cands) { if ($c -and (Test-Path $c)) { $script:GitExe = $c; return $c } }
return $null
}
function Invoke-Git {
param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs)
$exe = Get-GitExe
if (-not $exe) { return @{ exit = 9009; out = 'git nicht gefunden (weder im PATH noch an bekannten Installationsorten).' } }
$out = & $exe -C $RepoPath @GitArgs 2>&1
return @{ exit = $LASTEXITCODE; out = (@($out) -join "`n").Trim() }
}
function Get-PolicySnapshotFolderName {
# Tenant-Unterordner im Repo: Label (Multi-Tenant) sonst TenantId sonst 'default'.
$active = Get-ActiveConnection -Settings $script:Settings
$name = if ($active.label) { $active.label } elseif ($script:State.TenantId) { [string]$script:State.TenantId } else { 'default' }
$safe = ($name -replace '[^\w\-\.]', '_')
if ([string]::IsNullOrWhiteSpace($safe)) { $safe = 'default' }
return $safe
}
function Invoke-PolicyGitSnapshotEndpoint {
try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } }
$cfg = $script:Settings.policyBackup
$repo = if ($cfg -and $cfg.gitRepoPath) { [string]$cfg.gitRepoPath } else { '' }
$doPush = if ($cfg -and $cfg.push) { [bool]$cfg.push } else { $false }
if ([string]::IsNullOrWhiteSpace($repo)) {
return @{ __status = 400; error = 'Kein Git-Repo-Pfad konfiguriert (Einstellungen -> Policy-Backup).' }
}
# git verfuegbar? (PATH + bekannte Installationsorte)
if (-not (Get-GitExe)) {
return @{ __status = 500; error = 'git ist nicht auffindbar (weder im PATH des Server-Prozesses noch an den Standard-Installationsorten). Ggf. Server nach der Git-Installation neu starten.' }
}
# Repo-Ordner + .git sicherstellen
if (-not (Test-Path $repo)) { New-Item -ItemType Directory -Path $repo -Force | Out-Null }
if (-not (Test-Path (Join-Path $repo '.git'))) {
$r = Invoke-Git -RepoPath $repo -GitArgs @('init')
if ($r.exit -ne 0) { return @{ __status = 500; error = "git init fehlgeschlagen: $($r.out)" } }
}
# Commit-Identitaet sicherstellen (frisches Repo hat evtl. keine).
if ([string]::IsNullOrWhiteSpace((Invoke-Git -RepoPath $repo -GitArgs @('config','user.email')).out)) {
Invoke-Git -RepoPath $repo -GitArgs @('config','user.email','intune-manager@localhost') | Out-Null
Invoke-Git -RepoPath $repo -GitArgs @('config','user.name','Intune Manager') | Out-Null
}
$tenantFolder = Get-PolicySnapshotFolderName
$tenantDir = Join-Path $repo $tenantFolder
# Tenant-Ordner komplett neu aufbauen -> entfernte Policies verschwinden (Diff).
if (Test-Path $tenantDir) { Remove-Item $tenantDir -Recurse -Force }
New-Item -ItemType Directory -Path $tenantDir -Force | Out-Null
$types = @('settingscatalog','compliance','configuration','administrativetemplate')
$total = 0
$perType = [ordered]@{}
foreach ($type in $types) {
$tcfg = Get-PolicyTypeConfig $type
$list = @(Get-GraphPolicyList -Type $type)
$perType[$tcfg.ExportType] = $list.Count
if ($list.Count -eq 0) { continue }
$typeDir = Join-Path $tenantDir $type
New-Item -ItemType Directory -Path $typeDir -Force | Out-Null
foreach ($item in $list) {
$id = [string]$item.id
$detail = $null
try { $detail = Get-GraphPolicyDetail -Type $type -Id $id } catch { continue }
$name = [string]$item.name
$safe = ($name -replace '[^\w\-\.]', '_'); if (-not $safe) { $safe = $id }
$short = if ($id.Length -ge 8) { $id.Substring(0,8) } else { $id }
$fname = "$($safe)__$($short).json"
$envelope = [ordered]@{
policyType = $tcfg.ExportType
policyName = $name
policy = $detail
}
# stabile (sortierte) Ausgabe, ohne Zeitstempel -> saubere Diffs
(ConvertTo-StableObject $envelope) | ConvertTo-Json -Depth 50 | Set-Content -Path (Join-Path $typeDir $fname) -Encoding UTF8
$total++
}
}
$add = Invoke-Git -RepoPath $repo -GitArgs @('add','-A')
if ($add.exit -ne 0) { return @{ __status = 500; error = "git add fehlgeschlagen: $($add.out)" } }
if ([string]::IsNullOrWhiteSpace((Invoke-Git -RepoPath $repo -GitArgs @('status','--porcelain')).out)) {
return @{ ok = $true; changed = $false; committed = $false; total = $total; perType = $perType; tenant = $tenantFolder; message = 'Keine Aenderungen seit dem letzten Snapshot.' }
}
$msg = "Policy-Snapshot $tenantFolder $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
$commit = Invoke-Git -RepoPath $repo -GitArgs @('commit','-m',$msg)
if ($commit.exit -ne 0) { return @{ __status = 500; error = "git commit fehlgeschlagen: $($commit.out)" } }
$hash = (Invoke-Git -RepoPath $repo -GitArgs @('rev-parse','--short','HEAD')).out
$pushed = $false; $pushError = $null
if ($doPush) {
$push = Invoke-Git -RepoPath $repo -GitArgs @('push')
if ($push.exit -eq 0) { $pushed = $true } else { $pushError = $push.out }
}
$summary = "Snapshot committet: $total Policies ($hash)"
if ($doPush) { $summary += if ($pushed) { ', gepusht' } else { ', Push fehlgeschlagen' } }
return @{
ok = $true; changed = $true; committed = $true
total = $total; perType = $perType; tenant = $tenantFolder
commit = $hash; pushed = $pushed; pushError = $pushError
message = $summary
}
}
+1386 -80
View File
File diff suppressed because it is too large Load Diff
+354 -6
View File
@@ -37,9 +37,14 @@
<button class="nav-tab" data-view="report" aria-selected="false">App Report</button> <button class="nav-tab" data-view="report" aria-selected="false">App Report</button>
<button class="nav-tab" data-view="devices" aria-selected="false">Geräte</button> <button class="nav-tab" data-view="devices" aria-selected="false">Geräte</button>
<button class="nav-tab" data-view="policies" aria-selected="false">Policies</button> <button class="nav-tab" data-view="policies" aria-selected="false">Policies</button>
<button class="nav-tab" data-view="offboard" aria-selected="false">Offboarding</button>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<a id="updateBadge" class="update-badge hidden" href="#" target="_blank" rel="noopener" title="Update verfügbar — Release-Seite öffnen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg>
<span id="updateBadgeText">Update</span>
</a>
<button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen"> <button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>
</button> </button>
@@ -125,7 +130,7 @@
<section class="panel panel-targets"> <section class="panel panel-targets">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<h2>Abteilungen &amp; Benutzer</h2> <h2>Gruppen &amp; Benutzer</h2>
<span id="targetCount" class="count-badge">0</span> <span id="targetCount" class="count-badge">0</span>
</div> </div>
<button id="btnReloadTargets" class="icon-btn" title="Neu laden" aria-label="Neu laden"> <button id="btnReloadTargets" class="icon-btn" title="Neu laden" aria-label="Neu laden">
@@ -134,7 +139,7 @@
</div> </div>
<div class="mode-segment" role="tablist"> <div class="mode-segment" role="tablist">
<button class="seg active" data-mode="dept" role="tab">Abteilung</button> <button class="seg active" data-mode="dept" role="tab">Gruppe</button>
<button class="seg" data-mode="user" role="tab">Benutzer</button> <button class="seg" data-mode="user" role="tab">Benutzer</button>
</div> </div>
@@ -192,6 +197,11 @@
<option value="srcIntune">Nur Intune-Apps</option> <option value="srcIntune">Nur Intune-Apps</option>
<!-- Vendor-Optionen werden dynamisch von rebuildVendorFilterOptions() ergaenzt --> <!-- Vendor-Optionen werden dynamisch von rebuildVendorFilterOptions() ergaenzt -->
</select> </select>
<select id="appPlatformFilter" class="select" title="Nach Plattform filtern">
<option value="all">Alle Plattformen</option>
<option value="windows">Windows</option>
<option value="macos">macOS</option>
</select>
<select id="appCategoryFilter" class="select" title="Nach App-Kategorie filtern"> <select id="appCategoryFilter" class="select" title="Nach App-Kategorie filtern">
<option value="">Alle Kategorien</option> <option value="">Alle Kategorien</option>
<!-- Kategorie-Optionen werden dynamisch von rebuildCategoryFilterOptions() befuellt --> <!-- Kategorie-Optionen werden dynamisch von rebuildCategoryFilterOptions() befuellt -->
@@ -243,7 +253,7 @@
<div class="summary-section"> <div class="summary-section">
<div class="summary-section-title">Ausgewählte Empfänger</div> <div class="summary-section-title">Ausgewählte Empfänger</div>
<div class="summary-row sub"> <div class="summary-row sub">
<span class="muted">Abteilungsgruppen</span> <span class="muted">Gruppen</span>
<span id="sumGroupsSel">0</span> <span id="sumGroupsSel">0</span>
</div> </div>
<div class="summary-row sub"> <div class="summary-row sub">
@@ -254,7 +264,7 @@
<div class="summary-section"> <div class="summary-section">
<div class="summary-section-title">Zuweisungen erhalten</div> <div class="summary-section-title">Zuweisungen erhalten</div>
<div class="summary-row sub"> <div class="summary-row sub">
<span class="muted">Abteilungsgruppen</span> <span class="muted">Gruppen</span>
<span id="sumGroupsOps">0</span> <span id="sumGroupsOps">0</span>
</div> </div>
<div class="summary-row sub"> <div class="summary-row sub">
@@ -291,7 +301,7 @@
<!-- Tab-Leiste --> <!-- Tab-Leiste -->
<div class="gex-tab-bar group-mgmt-tabs"> <div class="gex-tab-bar group-mgmt-tabs">
<button class="gex-tab active" data-tab="export">Mitglieder &amp; Export</button> <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 gex-add-pane" data-tab="import">CSV-Import</button>
<button class="gex-tab" data-tab="user">Benutzer</button> <button class="gex-tab" data-tab="user">Benutzer</button>
</div> </div>
@@ -380,7 +390,11 @@
<!-- Rechte Spalte: Benutzer hinzufügen --> <!-- Rechte Spalte: Benutzer hinzufügen -->
<div class="gex-right hidden" id="gexPaneAdd"> <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;"> <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> <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"> <input type="search" id="gexUserSearch" class="input input-search" placeholder="Name, UPN oder E-Mail…" autocomplete="off">
@@ -486,6 +500,21 @@
<option value="noncompliant">Non-Compliant</option> <option value="noncompliant">Non-Compliant</option>
<option value="unknown">Unbekannt</option> <option value="unknown">Unbekannt</option>
</select> </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>
<div class="dev-layout"> <div class="dev-layout">
@@ -494,9 +523,11 @@
<table class="dev-table" id="devTable"> <table class="dev-table" id="devTable">
<thead> <thead>
<tr> <tr>
<th class="dev-col-check"><input type="checkbox" id="devCheckAll" title="Alle sichtbaren auswählen"></th>
<th class="sortable" data-col="deviceName">Gerät <span class="sort-ico"></span></th> <th class="sortable" data-col="deviceName">Gerät <span class="sort-ico"></span></th>
<th class="sortable" data-col="userDisplayName">Benutzer <span class="sort-ico"></span></th> <th class="sortable" data-col="userDisplayName">Benutzer <span class="sort-ico"></span></th>
<th class="sortable" data-col="operatingSystem">OS <span class="sort-ico"></span></th> <th class="sortable" data-col="operatingSystem">OS <span class="sort-ico"></span></th>
<th class="sortable" data-col="managementType">Verwaltung <span class="sort-ico"></span></th>
<th class="sortable" data-col="complianceState">Compliance <span class="sort-ico"></span></th> <th class="sortable" data-col="complianceState">Compliance <span class="sort-ico"></span></th>
<th class="sortable" data-col="lastSyncDateTime">Letzter Sync <span class="sort-ico"></span></th> <th class="sortable" data-col="lastSyncDateTime">Letzter Sync <span class="sort-ico"></span></th>
</tr> </tr>
@@ -572,6 +603,7 @@
<option value="settingscatalog">Settings Catalog</option> <option value="settingscatalog">Settings Catalog</option>
<option value="compliance">Compliance</option> <option value="compliance">Compliance</option>
<option value="configuration">Konfigurationsprofil</option> <option value="configuration">Konfigurationsprofil</option>
<option value="administrativetemplate">Administrative Vorlage</option>
</select> </select>
<span class="pol-toolbar-spacer" style="flex:1;"></span> <span class="pol-toolbar-spacer" style="flex:1;"></span>
<button class="btn btn-secondary btn-sm" id="btnPolRefresh" title="Policies neu laden"> <button class="btn btn-secondary btn-sm" id="btnPolRefresh" title="Policies neu laden">
@@ -583,6 +615,18 @@
Import… Import…
</button> </button>
<input type="file" id="polImportFile" accept=".json,application/json" multiple style="display:none;"> <input type="file" id="polImportFile" accept=".json,application/json" multiple style="display:none;">
<button class="btn btn-secondary btn-sm" id="btnPolGitSnapshot" title="Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben und committen">
<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"> <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> <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>) Export (<span id="polSelCount">0</span>)
@@ -606,6 +650,44 @@
</div> </div>
</section> </section>
<!-- ======================= OFFBOARDING ======================= -->
<section class="off-view hidden" id="offView">
<div class="off-bar">
<select id="offSearchType" class="input" style="height:34px;max-width:170px;">
<option value="name">Gerätename</option>
<option value="serial">Seriennummer</option>
</select>
<input id="offSearch" class="input" placeholder="Suchen… (Enter)" style="max-width:320px;height:34px;">
<button class="btn btn-secondary btn-sm" id="offSearchBtn">Suchen</button>
<span class="off-bar-spacer" style="flex:1;"></span>
<span id="offSelCount" class="muted">0 ausgewählt</span>
<button class="btn btn-danger btn-sm" id="offBtn" disabled>Offboarding…</button>
</div>
<div class="off-note muted">
Entfernt Geräte aus <b>Intune</b>, <b>Autopilot</b> und <b>Entra ID</b>. BitLocker-/FileVault-Keys und
LAPS-Passwörter werden vor dem Löschen angezeigt. Benötigt zusätzliche Berechtigungen &amp; Admin-Rollen
(siehe <code>docs/App-Registration.md</code>) — im Read-Only-Modus gesperrt.
</div>
<div class="off-table-wrap">
<table class="pol-table off-table" id="offTable">
<thead>
<tr>
<th style="width:34px;"><input type="checkbox" id="offCheckAll"></th>
<th>Gerät</th>
<th>Seriennr.</th>
<th>OS</th>
<th>Benutzer</th>
<th>Letzter Sync</th>
<th>Dienste</th>
</tr>
</thead>
<tbody id="offBody">
<tr><td colspan="7" class="pol-hint">Nach Geräten suchen…</td></tr>
</tbody>
</table>
</div>
</section>
<!-- MODAL: Wipe --> <!-- MODAL: Wipe -->
<div class="modal-backdrop hidden" id="wipeModalBackdrop"> <div class="modal-backdrop hidden" id="wipeModalBackdrop">
<div class="modal" style="max-width:420px;"> <div class="modal" style="max-width:420px;">
@@ -667,6 +749,13 @@
<span class="cg-mode-desc">Neue Required-Gruppe nach Naming-Schema erstellen und zuweisen.</span> <span class="cg-mode-desc">Neue Required-Gruppe nach Naming-Schema erstellen und zuweisen.</span>
</span> </span>
</label> </label>
<label class="cg-mode">
<input type="radio" name="cgMode" value="req-existing">
<span class="cg-mode-body">
<span class="cg-mode-title">Bestehende Gruppe</span>
<span class="cg-mode-desc">Eine vorhandene Entra-Gruppe als Required zuweisen.</span>
</span>
</label>
<label class="cg-mode"> <label class="cg-mode">
<input type="radio" name="cgMode" value="req-allusers"> <input type="radio" name="cgMode" value="req-allusers">
<span class="cg-mode-body"> <span class="cg-mode-body">
@@ -695,6 +784,13 @@
<span class="cg-mode-desc">Neue Available-Gruppe nach Naming-Schema erstellen und zuweisen.</span> <span class="cg-mode-desc">Neue Available-Gruppe nach Naming-Schema erstellen und zuweisen.</span>
</span> </span>
</label> </label>
<label class="cg-mode">
<input type="radio" name="cgMode" value="avail-existing">
<span class="cg-mode-body">
<span class="cg-mode-title">Bestehende Gruppe</span>
<span class="cg-mode-desc">Eine vorhandene Entra-Gruppe als Available zuweisen.</span>
</span>
</label>
<label class="cg-mode"> <label class="cg-mode">
<input type="radio" name="cgMode" value="avail-allusers"> <input type="radio" name="cgMode" value="avail-allusers">
<span class="cg-mode-body"> <span class="cg-mode-body">
@@ -718,6 +814,12 @@
<input type="text" id="cgGroupName" class="input"> <input type="text" id="cgGroupName" class="input">
<div id="cgStatus" class="form-hint"></div> <div id="cgStatus" class="form-hint"></div>
</div> </div>
<div class="form-group" id="cgExistingGroup" hidden>
<label class="lbl" for="cgExistingSearch">Bestehende Gruppe suchen</label>
<input type="search" id="cgExistingSearch" class="input" placeholder="Gruppenname… (min. 2 Zeichen)" autocomplete="off">
<div id="cgExistingResults" class="cg-existing-results"></div>
<div id="cgExistingSelected" class="form-hint"></div>
</div>
<label class="check-chip" id="cgAssignToAppWrap"> <label class="check-chip" id="cgAssignToAppWrap">
<input type="checkbox" id="cgAssignToApp" checked> <input type="checkbox" id="cgAssignToApp" checked>
<span>Gruppe direkt der App zuweisen</span> <span>Gruppe direkt der App zuweisen</span>
@@ -808,6 +910,233 @@
</div> </div>
</div> </div>
<!-- MODAL: Policy-Import (optional umbenennen) -->
<div id="modalPolImport" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Policies importieren</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;">
Namen können vor dem Import angepasst werden. Die Policies werden als
<b>Neuanlage</b> im verbundenen Tenant erstellt — bestehende bleiben unverändert.
</div>
<div id="polImportList"></div>
</div>
<div class="modal-foot">
<span id="polImportInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="polImportConfirm">Importieren</button>
</div>
</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>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Importierte Policies 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;">
Optional: jeder importierten Policy Include- und/oder Exclude-Gruppen im aktuellen Tenant zuweisen.
Felder leer lassen = keine Zuweisung. Original-Zuweisungen des Quell-Tenants werden nicht übernommen.
</div>
<div id="polAssignList"></div>
</div>
<div class="modal-foot">
<span id="polAssignInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Überspringen</button>
<button class="btn btn-primary" id="polAssignApply">Zuweisen</button>
</div>
</div>
</div>
<!-- Bulk: mehreren ausgewählten Policies dieselben Gruppen zuweisen -->
<div id="modalPolBulkAssign" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Gruppen zuweisen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-hint" style="margin-bottom:12px;">
Die gewählten Include-/Exclude-Gruppen werden auf <b><span id="polBulkCount">0</span></b> ausgewählte Policy(s) angewendet.
</div>
<div id="polBulkNames" class="pol-bulk-names"></div>
<div class="pol-assign-pickers" style="margin-top:12px;">
<div class="pol-assign-picker" data-kind="include">
<label class="lbl">Include-Gruppen</label>
<div class="pol-assign-chips" data-scope="bulk" data-kind="include"></div>
<div class="pol-assign-search-wrap">
<input type="text" class="input pol-bulk-search" data-kind="include" placeholder="Gruppe suchen…" autocomplete="off">
<div class="pol-assign-dropdown pol-bulk-dropdown hidden" data-kind="include"></div>
</div>
</div>
<div class="pol-assign-picker" data-kind="exclude">
<label class="lbl">Exclude-Gruppen</label>
<div class="pol-assign-chips" data-scope="bulk" data-kind="exclude"></div>
<div class="pol-assign-search-wrap">
<input type="text" class="input pol-bulk-search" data-kind="exclude" placeholder="Gruppe suchen…" autocomplete="off">
<div class="pol-assign-dropdown pol-bulk-dropdown hidden" data-kind="exclude"></div>
</div>
</div>
</div>
<div class="pol-bulk-builtin" style="margin-top:14px;">
<label class="lbl">Integrierte Ziele (Include)</label>
<label class="pol-bulk-check"><input type="checkbox" id="polBulkAllDevices"> Alle Geräte</label>
<label class="pol-bulk-check"><input type="checkbox" id="polBulkAllUsers"> Alle Benutzer</label>
</div>
<div class="pol-bulk-mode" style="margin-top:16px;">
<label class="lbl">Modus</label>
<label class="pol-bulk-radio"><input type="radio" name="polBulkMode" value="add" checked>
<span><b>Hinzufügen</b> — bestehende Zuweisungen bleiben erhalten, neue Gruppen kommen dazu.</span></label>
<label class="pol-bulk-radio"><input type="radio" name="polBulkMode" value="replace">
<span><b>Ersetzen</b> — alle bisherigen Zuweisungen der Policy werden durch die gewählten Gruppen ersetzt.</span></label>
</div>
</div>
<div class="modal-foot">
<span id="polBulkInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="polBulkApply" disabled>Zuweisen</button>
</div>
</div>
</div>
<!-- MODAL: Geräte-Offboarding -->
<div id="modalOffboard" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Geräte offboarden</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="off-warn">⚠ Entfernt die Geräte aus den gewählten Diensten. Löschen ist <b>nicht umkehrbar</b>. Recovery-Keys unbedingt vorher sichern.</div>
<div id="offConfirmList" class="off-confirm-list"></div>
<div class="off-services">
<div class="lbl">Dienste</div>
<div class="off-svc-row">
<span class="off-svc-label">Entra ID:</span>
<label class="off-radio"><input type="radio" name="offEntra" value="none" checked> nichts tun</label>
<label class="off-radio"><input type="radio" name="offEntra" value="disable"> deaktivieren</label>
<label class="off-radio"><input type="radio" name="offEntra" value="delete"> löschen</label>
</div>
<label class="check-chip"><input type="checkbox" id="offSvcIntune" checked><span>Aus Intune löschen</span></label>
<label class="check-chip"><input type="checkbox" id="offSvcAutopilot"><span>Aus Autopilot löschen</span></label>
</div>
<div id="offKeys" class="off-keys"></div>
<label class="check-chip off-confirm-chip"><input type="checkbox" id="offConfirmChk"><span>Ich verstehe, dass die Geräte gelöscht/entfernt werden.</span></label>
<div id="offResults"></div>
</div>
<div class="modal-foot">
<span id="offModalInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-danger" id="offExecuteBtn" disabled>Offboarding ausführen</button>
</div>
</div>
</div>
<style>
/* Nutzt die Theme-Variablen der App -> passt sich Light/Dark automatisch an. */
.pol-assign-row { border:1px solid var(--hairline); border-radius:8px; padding:12px 14px; margin-bottom:12px; overflow-wrap:anywhere; }
.pol-assign-head { display:flex; align-items:baseline; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.pol-assign-head .pol-name { font-weight:600; color:var(--ink); min-width:0; word-break:break-word; overflow-wrap:anywhere; }
.pol-assign-head .pol-type-badge { flex:0 0 auto; }
.pol-assign-pickers { display:flex; gap:16px; flex-wrap:wrap; }
.pol-assign-picker { flex:1; min-width:220px; }
.pol-assign-picker > .lbl { display:block; font-size:12px; margin-bottom:4px; color:var(--body); }
.pol-assign-picker[data-kind="exclude"] > .lbl { color:var(--pink); }
.pol-assign-chips { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; }
.pol-assign-chip { display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border-radius:12px;
background:var(--surface-strong); color:var(--ink); font-size:12px; }
.pol-assign-chip.exclude { background:rgba(236,72,153,0.18); color:var(--ink); }
.pol-assign-chip button { border:none; background:none; cursor:pointer; font-size:14px; line-height:1; padding:0; color:var(--muted); }
.pol-assign-chip button:hover { color:var(--ink); }
.pol-assign-search-wrap { position:relative; }
.pol-assign-dropdown { position:absolute; z-index:20; left:0; right:0; top:100%; margin-top:2px;
background:var(--canvas); border:1px solid var(--hairline); border-radius:6px;
max-height:200px; overflow-y:auto; box-shadow:0 6px 18px rgba(0,0,0,.25); }
.pol-assign-dropdown .opt { padding:6px 10px; cursor:pointer; font-size:13px; color:var(--body); }
.pol-assign-dropdown .opt:hover { background:var(--surface-soft); }
.pol-assign-dropdown .opt .desc { font-size:11px; color:var(--muted); }
.pol-assign-dropdown .msg { padding:6px 10px; font-size:12px; color:var(--muted); }
.pol-import-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--hairline); }
.pol-import-row:last-child { border-bottom:none; }
.pol-import-name { flex:1; min-width:0; }
.pol-import-row .pol-type-badge { flex:0 0 auto; }
/* Offboarding */
.off-view { padding:16px 20px; }
.off-bar { display:flex; align-items:center; gap:10px; margin-bottom:10px; flex-wrap:wrap; }
.off-note { font-size:12.5px; margin-bottom:12px; line-height:1.5; }
.off-table-wrap { overflow-x:auto; }
.off-table td, .off-table th { white-space:nowrap; }
.off-badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:var(--surface-strong); color:var(--ink); margin-right:3px; }
.off-badge.warn { background:rgba(245,158,11,0.22); }
.off-warn { background:rgba(239,68,68,0.14); border:1px solid rgba(239,68,68,0.35); color:var(--ink);
border-radius:8px; padding:10px 12px; font-size:13px; margin-bottom:12px; }
.off-confirm-list { margin-bottom:12px; }
.off-confirm-row { font-size:13px; padding:4px 0; border-bottom:1px solid var(--hairline); overflow-wrap:anywhere; }
.off-confirm-row:last-child { border-bottom:none; }
.off-services { margin:12px 0; padding:12px; border:1px solid var(--hairline); border-radius:8px; }
.off-services .lbl { display:block; font-size:12px; color:var(--body); margin-bottom:8px; }
.off-svc-row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:10px; }
.off-svc-label { font-size:13px; color:var(--ink); font-weight:600; }
.off-radio { display:inline-flex; align-items:center; gap:5px; font-size:13px; cursor:pointer; }
.off-keys { margin:12px 0; }
.off-keys .lbl { display:block; font-size:12px; color:var(--pink); margin-bottom:6px; }
.off-key-dev { border:1px solid var(--hairline); border-radius:6px; padding:8px 10px; margin-bottom:6px; font-size:12.5px; overflow-wrap:anywhere; }
.off-key { margin-top:3px; }
.off-key code { background:var(--surface-strong); color:var(--ink); padding:1px 5px; border-radius:4px; user-select:all; }
.off-confirm-chip { margin-top:8px; }
.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>
<!-- MODAL: Hilfe --> <!-- MODAL: Hilfe -->
<div id="modalHelp" class="modal hidden"> <div id="modalHelp" class="modal hidden">
<div class="modal-backdrop" data-close></div> <div class="modal-backdrop" data-close></div>
@@ -1033,6 +1362,25 @@
<div class="form-hint">Vorschau: <code id="setAvailPreview" class="set-preview"></code></div> <div class="form-hint">Vorschau: <code id="setAvailPreview" class="set-preview"></code></div>
</section> </section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Policy-Backup (Git)</h4>
<div class="settings-sec-sub">Lokaler Git-Ordner für Policy-Snapshots. Der „Git-Snapshot"-Button im Policies-Tab schreibt alle Policies als JSON dorthin und committet sie (optional Push).</div>
</div>
<div class="form-group">
<label class="lbl" for="setPolicyGitPath">Git-Repo-Ordner (lokal)</label>
<div class="form-row" style="gap:8px;align-items:center;">
<input type="text" id="setPolicyGitPath" class="input mono" placeholder="z. B. C:\Intune-Backup" spellcheck="false" autocomplete="off" style="flex:1;">
<button type="button" class="btn btn-secondary btn-sm" id="btnPickPolicyGit">Ordner…</button>
</div>
<div class="form-hint">Wird bei Bedarf angelegt und als Git-Repo initialisiert. Leer = Funktion deaktiviert.</div>
</div>
<div class="form-group">
<label class="check-chip"><input type="checkbox" id="setPolicyGitPush"><span>Nach dem Commit automatisch <code>git push</code></span></label>
<div class="form-hint">Nutzt deinen vorhandenen Git-Credential-Helper (Remote muss im Repo konfiguriert sein). Kein Token in der App.</div>
</div>
</section>
<section class="settings-sec"> <section class="settings-sec">
<div class="settings-sec-head"> <div class="settings-sec-head">
<h4>Branding — Logo</h4> <h4>Branding — Logo</h4>
+185
View File
@@ -335,6 +335,24 @@ h1, h2, h3 {
.header-actions { display: flex; align-items: center; gap: 12px; } .header-actions { display: flex; align-items: center; gap: 12px; }
/* Update-Hinweis-Badge (neue Version verfuegbar) */
.update-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--brand-accent);
border: 1px solid var(--brand-accent);
font-size: 12px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition: filter var(--tx, 0.15s ease-out);
}
.update-badge:hover { filter: brightness(0.96); }
/* Theme-Toggle — Slide-Switch */ /* Theme-Toggle — Slide-Switch */
.theme-toggle { .theme-toggle {
appearance: none; appearance: none;
@@ -1129,6 +1147,21 @@ a.target-link:hover {
} }
.target-row:hover .entra-link, .target-row:hover .entra-link,
.ex-member-row:hover .entra-link { opacity: 1; } .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 { .entra-link:hover {
background: var(--surface-soft); background: var(--surface-soft);
color: var(--brand-accent); color: var(--brand-accent);
@@ -1327,6 +1360,7 @@ html[data-theme="dark"] .entra-link.teams-link:hover {
border-radius: var(--r-md); border-radius: var(--r-md);
} }
.member-row { .member-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4020,6 +4054,67 @@ body.members-page {
color: var(--muted); color: var(--muted);
} }
/* "Bestehende Gruppe" — Suchergebnis-Liste im Zuweisungs-Dialog */
.cg-existing-results {
margin-top: 8px;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--hairline-soft);
border-radius: var(--r-xs);
}
.cg-existing-results:empty { display: none; }
.cg-existing-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: none;
border-bottom: 1px solid var(--hairline-soft);
cursor: pointer;
color: inherit;
}
.cg-existing-item:last-child { border-bottom: none; }
.cg-existing-item:hover { background: var(--hover, rgba(127,127,127,0.08)); }
.cg-existing-item.is-selected { background: var(--accent-soft); }
.cg-existing-name { font-size: 13px; font-weight: 600; }
.cg-existing-desc { font-size: 11px; color: var(--muted); }
.cg-existing-empty { padding: 10px 12px; font-size: 12px; color: var(--muted); }
/* Ausgewaehlte Gruppen als Chips (Mehrfachauswahl) */
.cg-existing-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.cg-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 6px 3px 10px;
background: var(--accent-soft);
color: var(--brand-accent);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
max-width: 100%;
}
.cg-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
.cg-chip-x {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 0 2px;
opacity: 0.75;
}
.cg-chip-x:hover { opacity: 1; }
.cg-mode { .cg-mode {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -4554,6 +4649,25 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown {
flex-shrink: 0; 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 { .gex-tab {
padding: 10px 18px; padding: 10px 18px;
font-size: 13px; font-size: 13px;
@@ -4873,6 +4987,7 @@ body.read-only .gex-add-pane,
body.read-only .gex-tab[data-tab="add"], body.read-only .gex-tab[data-tab="add"],
body.read-only .gex-tab[data-tab="import"], body.read-only .gex-tab[data-tab="import"],
body.read-only #gexPaneImport, body.read-only #gexPaneImport,
body.read-only .dev-offboard-btn,
body.read-only .pol-write { body.read-only .pol-write {
display: none !important; display: none !important;
} }
@@ -5277,6 +5392,8 @@ body.read-only .app-row {
} }
.dev-table thead th.sortable { cursor: pointer; } .dev-table thead th.sortable { cursor: pointer; }
.dev-table thead th.sortable:hover { color: var(--text); } .dev-table thead th.sortable:hover { color: var(--text); }
.dev-col-check { width: 34px; text-align: center; padding-left: 6px; padding-right: 6px; }
.dev-col-check input { cursor: pointer; }
.dev-table tbody tr { .dev-table tbody tr {
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--hairline-soft); border-bottom: 1px solid var(--hairline-soft);
@@ -5310,6 +5427,8 @@ body.read-only .app-row {
.dev-badge-ok { background: rgba(74,222,128,.15); color: #4ade80; } .dev-badge-ok { background: rgba(74,222,128,.15); color: #4ade80; }
.dev-badge-err { background: rgba(248,113,113,.15); color: #f87171; } .dev-badge-err { background: rgba(248,113,113,.15); color: #f87171; }
.dev-badge-unk { background: var(--hairline-soft); color: var(--muted); } .dev-badge-unk { background: var(--hairline-soft); color: var(--muted); }
.dev-badge-intune { background: rgba(96,165,250,.15); color: #60a5fa; }
.dev-badge-comgmt { background: rgba(251,191,36,.15); color: #fbbf24; }
/* Detail-Panel */ /* Detail-Panel */
.dev-detail { .dev-detail {
@@ -5367,6 +5486,31 @@ body.read-only .app-row {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; 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 { width: 100%; border-collapse: collapse; font-size: 13px; }
.pol-table thead { position: sticky; top: 0; z-index: 2; background: var(--bg2); } .pol-table thead { position: sticky; top: 0; z-index: 2; background: var(--bg2); }
.pol-table th { .pol-table th {
@@ -5408,6 +5552,47 @@ body.read-only .app-row {
font-size: 13px; font-size: 13px;
} }
/* Gruppen-Zuweisungs-Picker (Post-Import & Bulk) */
.pol-assign-pickers { display: flex; gap: 16px; flex-wrap: wrap; }
.pol-assign-picker { flex: 1 1 240px; min-width: 220px; }
.pol-assign-picker .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
.pol-assign-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.pol-assign-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 4px 2px 9px; border-radius: var(--r-pill, 999px);
font-size: 12px; background: rgba(96,165,250,.15); color: #60a5fa;
}
.pol-assign-chip.exclude { background: rgba(248,113,113,.15); color: #f87171; }
.pol-assign-chip button {
border: 0; background: transparent; color: inherit; cursor: pointer;
font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%;
}
.pol-assign-chip button:hover { background: rgba(0,0,0,.15); }
.pol-assign-search-wrap { position: relative; }
.pol-assign-dropdown {
position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 2px);
max-height: 240px; overflow-y: auto;
background: var(--canvas, #1b1b1b); border: 1px solid var(--hairline, #333);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
.pol-assign-dropdown .opt { padding: 7px 10px; cursor: pointer; font-size: 13px; }
.pol-assign-dropdown .opt:hover { background: var(--hover, rgba(255,255,255,.06)); }
.pol-assign-dropdown .opt .desc { font-size: 11px; color: var(--text-dim, #888); margin-top: 1px; }
.pol-assign-dropdown .msg { padding: 8px 10px; font-size: 12px; color: var(--text-dim, #888); }
/* Bulk-Zuweisung: Policy-Liste + Modus */
.pol-bulk-names { display: flex; flex-wrap: wrap; gap: 6px; max-height: 120px; overflow-y: auto; }
.pol-bulk-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px; border-radius: 6px; font-size: 12.5px;
background: var(--bg3, var(--hover)); color: var(--ink, inherit);
}
.pol-bulk-mode .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
.pol-bulk-radio { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 8px; font-size: 13px; cursor: pointer; }
.pol-bulk-radio input { margin-top: 2px; }
.pol-bulk-builtin .lbl { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: var(--text-dim, #888); }
.pol-bulk-check { display: inline-flex; align-items: center; gap: 6px; margin-right: 18px; font-size: 13px; cursor: pointer; }
/* ============================================================= /* =============================================================
Multi-Tenant: Topbar-Umschalter + Profil-Editor Multi-Tenant: Topbar-Umschalter + Profil-Editor
============================================================= */ ============================================================= */