3 Commits
Author SHA1 Message Date
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
11 changed files with 1597 additions and 171 deletions
+54
View File
@@ -5,6 +5,60 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan
--- ---
## [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 ## [0.1.29] - 2026-08-24
Geräte-Offboarding (v1). Geräte-Offboarding (v1).
+22 -9
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
@@ -132,10 +132,10 @@ App-Registrierung automatisch anlegt/konfiguriert:**
| 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**), CSV-Export, **Bulk-Offboarding**, Detail-Panel, Aktionen (Sync, Wipe, …) |
| Policies | Policies exportieren (JSON-Download) und importieren (Neuanlage) | | Policies | Policies exportieren/importieren (Neuanlage) und **zusammenfuehren** (Settings Catalog) |
--- ---
@@ -148,10 +148,11 @@ App-Registrierung automatisch anlegt/konfiguriert:**
- 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)*
@@ -235,6 +236,16 @@ 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.
### Benoetigt Graph-Berechtigung ### Benoetigt Graph-Berechtigung
`DeviceManagementConfiguration.ReadWrite.All` (Import) bzw. `DeviceManagementConfiguration.ReadWrite.All` (Import) bzw.
@@ -295,8 +306,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 |
@@ -311,6 +323,7 @@ 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 |
> **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All` > **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All`
> **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import) > **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import)
+1 -1
View File
@@ -189,7 +189,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen Session = @() # geplante Zuweisungen
} }
$script:ToolVersion = "0.1.29" $script:ToolVersion = "0.1.32"
$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 ""
+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.29" $version = "0.1.32"
$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"
+204 -55
View File
@@ -58,10 +58,12 @@ function Invoke-ApiHandler {
"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/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/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint }
"POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body } "POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body }
"GET /api/offboard/search" { return Search-OffboardDevicesEndpoint -Query $Query } "GET /api/offboard/search" { return Search-OffboardDevicesEndpoint -Query $Query }
"POST /api/offboard/resolve" { return Get-OffboardResolveEndpoint -Body $Body }
"POST /api/offboard/keys" { return Get-OffboardKeysEndpoint -Body $Body } "POST /api/offboard/keys" { return Get-OffboardKeysEndpoint -Body $Body }
"POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body } "POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body }
} }
@@ -71,6 +73,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]
@@ -1608,6 +1613,117 @@ 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'
$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
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
# ============================================================ # ============================================================
@@ -1669,12 +1785,14 @@ function Search-DevicesEndpoint {
$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'
# 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.
@@ -1683,22 +1801,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)
@@ -1708,7 +1840,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)
} }
@@ -1728,12 +1862,12 @@ 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
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 }
@@ -1744,8 +1878,9 @@ 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,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 }
@@ -1758,12 +1893,12 @@ 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
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
@@ -2805,23 +2940,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 {}
@@ -2836,38 +2977,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 {
+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\.','')
} }
+52 -3
View File
@@ -55,6 +55,15 @@ function Search-OffboardDevicesEndpoint {
return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" } return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" }
} }
if ($intune.Count -eq 0) { return @{ items = @(); count = 0 } } if ($intune.Count -eq 0) { return @{ items = @(); count = 0 } }
return @{ items = @(Resolve-OffboardItems -IntuneDevices $intune); count = $intune.Count }
}
# Reichert Intune-managedDevice-Objekte mit Entra-Objekt-Id + Autopilot-Id an
# (per $batch) und liefert die Offboard-Item-Struktur fuers Frontend.
function Resolve-OffboardItems {
param([object[]]$IntuneDevices)
$intune = @($IntuneDevices)
if ($intune.Count -eq 0) { return @() }
# Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen. # Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen.
$reqs = @() $reqs = @()
@@ -77,9 +86,23 @@ function Search-OffboardDevicesEndpoint {
$items = @() $items = @()
$idx = 0 $idx = 0
foreach ($d in $intune) { foreach ($d in $intune) {
$entra = $null; $autop = $null $ser = [string]$d.serialNumber
$entra = $null; $autop = $null; $apNote = ''
$er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] } $er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] }
$ar = $batch["a$idx"]; if ($ar -and [int]$ar.status -eq 200) { $autop = @($ar.body.value)[0] } $ar = $batch["a$idx"]
if ($ar) {
$ast = [int]$ar.status
if ($ast -eq 200) {
$autop = @($ar.body.value)[0]
if (-not $autop) { $apNote = 'Kein Autopilot-Treffer fuer Seriennummer' }
} else {
$acode = ''; try { $acode = [string]$ar.body.error.code } catch {}
$apNote = "Autopilot-Lookup fehlgeschlagen: HTTP $ast" + $(if ($acode) { " ($acode)" } else { '' })
Write-Host " [OFFBOARD] Autopilot-Lookup ($($d.deviceName), SN=$ser): HTTP $ast $acode" -ForegroundColor DarkYellow
}
} elseif (-not $ser) {
$apNote = 'Keine Seriennummer am Intune-Geraet'
}
$items += [pscustomobject]@{ $items += [pscustomobject]@{
deviceName = [string]$d.deviceName deviceName = [string]$d.deviceName
serialNumber = [string]$d.serialNumber serialNumber = [string]$d.serialNumber
@@ -94,13 +117,39 @@ function Search-OffboardDevicesEndpoint {
entraObjectId = if ($entra) { [string]$entra.id } else { '' } entraObjectId = if ($entra) { [string]$entra.id } else { '' }
entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null } entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null }
autopilotId = if ($autop) { [string]$autop.id } else { '' } autopilotId = if ($autop) { [string]$autop.id } else { '' }
autopilotNote = $apNote
inIntune = $true inIntune = $true
inEntra = [bool]$entra inEntra = [bool]$entra
inAutopilot = [bool]$autop inAutopilot = [bool]$autop
} }
$idx++ $idx++
} }
return @{ items = @($items); count = $items.Count } return @($items)
}
# Offboard-Items zu einer Liste von Intune-Device-Ids aufloesen (fuer Bulk-
# Offboarding aus dem Geraete-Tab). Body: { ids: [intuneDeviceId, ...] }
function Get-OffboardResolveEndpoint {
param($Body)
$err = Test-Connected
if ($err) { return $err }
$ids = @(Get-PolicyProp $Body 'ids') | Where-Object { $_ }
if (@($ids).Count -eq 0) { return @{ items = @(); count = 0 } }
$sel = 'id,deviceName,serialNumber,operatingSystem,osVersion,userPrincipalName,lastSyncDateTime,azureADDeviceId,managedDeviceOwnerType,managementAgent'
$reqs = @()
$i = 0
foreach ($id in $ids) {
$reqs += @{ id = "d$i"; method = 'GET'; url = "/deviceManagement/managedDevices/$([string]$id)?`$select=$sel" }
$i++
}
$batch = Invoke-GraphBatch -Requests $reqs
$devs = @()
foreach ($k in @($batch.Keys)) {
$r = $batch[$k]
if ($r -and [int]$r.status -eq 200 -and $r.body) { $devs += $r.body }
}
return @{ items = @(Resolve-OffboardItems -IntuneDevices $devs); count = @($devs).Count }
} }
# Recovery-Keys eines Geraets holen (vor dem Loeschen). Body: # Recovery-Keys eines Geraets holen (vor dem Loeschen). Body:
+253 -10
View File
@@ -186,10 +186,12 @@ function Get-GraphPolicyDetail {
# Administrative Vorlagen: Basis-Objekt holen und die konfigurierten Werte # Administrative Vorlagen: Basis-Objekt holen und die konfigurierten Werte
# (definitionValues inkl. Definition + Presentation-Werten) separat expandieren. # (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') { if ($cfg.Key -eq 'administrativetemplate') {
$base = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" -Method GET $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)" $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) $dvs = @(Get-GraphPaged -Uri $dvUri -AsRawJson)
if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs } if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs }
else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force } else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force }
return $base return $base
@@ -197,7 +199,7 @@ function Get-GraphPolicyDetail {
$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.
@@ -229,10 +231,23 @@ function ConvertTo-ImportBody {
function Repair-SettingsCatalogArrays { function Repair-SettingsCatalogArrays {
param($Node) param($Node)
$arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','values') $arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','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: $null -> @() (Graph-Schema: Collections sind # Array-Property normalisieren — WICHTIG inline (Zuweisung, KEIN Funktions-
# Nullable=False, ein 'children': null wird abgelehnt), Einzelobjekt -> @(obj). # Return): ein leeres Array aus einer Funktion zurueckzugeben entpackt PS zu
# Inline (nicht als Funktion), sonst entpackt der Return ein Ein-Element-Array. # $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]) { if ($Node -is [System.Collections.IDictionary]) {
$out = [ordered]@{} $out = [ordered]@{}
foreach ($k in @($Node.Keys)) { foreach ($k in @($Node.Keys)) {
@@ -240,7 +255,9 @@ function Repair-SettingsCatalogArrays {
if ($k -in $arrayKeys) { if ($k -in $arrayKeys) {
if ($null -eq $fixed) { $fixed = @() } if ($null -eq $fixed) { $fixed = @() }
elseif (-not ($fixed -is [System.Collections.IList])) { $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 $out[$k] = $fixed
} }
return $out return $out
@@ -252,7 +269,9 @@ function Repair-SettingsCatalogArrays {
if ($p.Name -in $arrayKeys) { if ($p.Name -in $arrayKeys) {
if ($null -eq $fixed) { $fixed = @() } if ($null -eq $fixed) { $fixed = @() }
elseif (-not ($fixed -is [System.Collections.IList])) { $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 $out[$p.Name] = $fixed
} }
return $out return $out
@@ -267,6 +286,43 @@ function Repair-SettingsCatalogArrays {
return $Node 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.
@@ -320,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'
} }
@@ -350,6 +408,17 @@ function Import-GraphSettingsCatalogPolicy {
$body['settings'] = @() $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'
} }
@@ -669,6 +738,161 @@ function Invoke-PolicyAssignEndpoint {
return @{ ok = $true; assignedCount = $ok; results = @($results) } 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) # Policy-Snapshot nach Git (voller Export, stabile Dateinamen)
# ============================================================ # ============================================================
@@ -692,9 +916,27 @@ function ConvertTo-StableObject {
return $InputObject 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 { function Invoke-Git {
param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs) param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs)
$out = & git -C $RepoPath @GitArgs 2>&1 $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() } return @{ exit = $LASTEXITCODE; out = (@($out) -join "`n").Trim() }
} }
@@ -717,9 +959,10 @@ function Invoke-PolicyGitSnapshotEndpoint {
return @{ __status = 400; error = 'Kein Git-Repo-Pfad konfiguriert (Einstellungen -> Policy-Backup).' } return @{ __status = 400; error = 'Kein Git-Repo-Pfad konfiguriert (Einstellungen -> Policy-Backup).' }
} }
# git verfuegbar? # git verfuegbar? (PATH + bekannte Installationsorte)
try { $null = & git --version 2>&1; if ($LASTEXITCODE -ne 0) { throw 'x' } } if (-not (Get-GitExe)) {
catch { return @{ __status = 500; error = 'git ist nicht installiert oder nicht im PATH.' } } 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 # Repo-Ordner + .git sicherstellen
if (-not (Test-Path $repo)) { New-Item -ItemType Directory -Path $repo -Force | Out-Null } if (-not (Test-Path $repo)) { New-Item -ItemType Directory -Path $repo -Force | Out-Null }
+723 -68
View File
File diff suppressed because it is too large Load Diff
+87 -6
View File
@@ -126,7 +126,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">
@@ -135,7 +135,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>
@@ -193,6 +193,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 -->
@@ -244,7 +249,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">
@@ -255,7 +260,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">
@@ -292,7 +297,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>
@@ -381,7 +386,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">
@@ -487,6 +496,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">
@@ -495,6 +519,7 @@
<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>
@@ -589,6 +614,10 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/><circle cx="12" cy="3" r="1"/><circle cx="12" cy="21" r="1"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/><circle cx="12" cy="3" r="1"/><circle cx="12" cy="21" r="1"/></svg>
Git-Snapshot Git-Snapshot
</button> </button>
<button class="btn btn-secondary btn-sm pol-write" id="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>)
@@ -711,6 +740,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">
@@ -739,6 +775,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">
@@ -762,6 +805,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>
@@ -875,6 +924,38 @@
</div> </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 --> <!-- MODAL: Policy-Zuweisung nach Import -->
<div id="modalPolAssign" class="modal hidden"> <div id="modalPolAssign" class="modal hidden">
<div class="modal-backdrop" data-close></div> <div class="modal-backdrop" data-close></div>
+124
View File
@@ -1129,6 +1129,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 +1342,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 +4036,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 +4631,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 +4969,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 +5374,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);
@@ -5367,6 +5466,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 {