From ea51bac77298782baef27ec54c4dca9efcc9b0a4 Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Wed, 19 Aug 2026 14:25:29 +0200 Subject: [PATCH] Policies-Tab: Export/Import (Settings Catalog, Compliance, Config) Neuer Tab "Policies" zum Sichern und Wiederherstellen von Intune-Policies. - Backend src/PolicyIO.ps1: typ-getrieben fuer configurationPolicies (Settings Catalog), deviceCompliancePolicies und deviceConfigurations - Export als JSON-Download (einzeln/Bundle) + Server-Archiv unter %APPDATA% - Import per Datei-Upload, immer Neuanlage (nie ueberschreiben), robust fuer Hashtable (PS7) und PSCustomObject (PS5.1); im Read-Only-Modus ausgeblendet - Routen in src/Api.ps1; Config-Scope DeviceManagementConfiguration.* auch nach Settings-Save sichergestellt - Frontend: Tab + View, Suche/Typ-Filter/Sortierung, Auswahl, Download/Upload - Version 0.1.25 (Start.ps1, CHANGELOG, README) - build-local.ps1: x64-.NET-Runtime fuer wix auf ARM64-Windows + echter Desktop-Pfad Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 15 ++ README.md | 53 ++++- Start.ps1 | 13 +- installer/build-local.ps1 | 20 +- src/Api.ps1 | 16 ++ src/PolicyIO.ps1 | 427 ++++++++++++++++++++++++++++++++++++++ www/app.js | 244 +++++++++++++++++++++- www/index.html | 48 +++++ www/styles.css | 69 +++++- 9 files changed, 898 insertions(+), 7 deletions(-) create mode 100644 src/PolicyIO.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c38c04e..30e8cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [0.1.25] - 2026-08-19 + +Policy Export/Import und Geräte-Tab. + +### Neu + +- **Policies-Tab**: Export und Import von Intune-Policies — **Settings Catalog** (`configurationPolicies`), **Compliance Policies** (`deviceCompliancePolicies`) und **Konfigurationsprofile** (`deviceConfigurations`) + - Tabelle über alle drei Typen mit Suche, Typ-Filter und Sortierung; Auswahl per Checkbox + - **Export** als JSON-Download (einzeln oder als gebündelte Datei bei mehreren) — zusätzlich server-seitig unter `%APPDATA%\IntuneAppManager-Web\policy-exports` archiviert + - **Import** per Datei-Upload (einzelne Envelopes oder Bundle) — legt Policies **immer als Neuanlage** an, überschreibt nie bestehende; im Read-Only-Modus ausgeblendet + - Neuer Scope `DeviceManagementConfiguration.ReadWrite.All` (bzw. `.Read.All`) wird automatisch zur Verbindung ergänzt +- Neue Endpoints: `GET /api/policies/{settingscatalog|compliance|configuration}`, `POST /api/policies/export`, `GET /api/policies/exports`, `POST /api/policies/import` + +--- + ## [0.1.24] - 2026-07-03 Navigation-Tabs, Group Management, Read-Only-Modus, App Report, CSV-Import. diff --git a/README.md b/README.md index c712175..3dc7279 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ Settings → Verbindung | App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen | | Group Management | Mitglieder anzeigen, exportieren, Benutzer hinzufuegen, CSV-Import | | App Report | Installationszaehler pro App, Geraete-Export als CSV | +| Geräte | Geraete suchen, Detail-Panel, Aktionen (Sync, Wipe, …) | +| Policies | Policies exportieren (JSON-Download) und importieren (Neuanlage) | --- @@ -159,6 +161,47 @@ erika.muster@firma.de --- +## Policies (Export / Import) + +Sichert Intune-Policies als JSON und legt sie in einem (ggf. anderen) Tenant +neu an. Unterstuetzte Typen: + +| Typ | Graph-Collection | +|-------------------------|-----------------------------| +| Settings Catalog | `configurationPolicies` | +| Compliance Policies | `deviceCompliancePolicies` | +| Konfigurationsprofile | `deviceConfigurations` | + +### Export + +- Tabelle laedt alle drei Typen; Suche + Typ-Filter grenzen ein +- Policies per Checkbox auswaehlen → **Export** + - **1 Policy** → eine JSON-Datei + - **mehrere** → eine gebündelte JSON-Datei (`intune-policies_.json`) +- Zusaetzlich wird jeder Export server-seitig unter + `%APPDATA%\IntuneAppManager-Web\policy-exports` archiviert + +### Import *(nur Read/Write)* + +- **Import…** → eine oder mehrere JSON-Dateien waehlen (einzeln oder Bundle) +- Import legt die Policies **immer als neue Policy** an — bestehende Policies + werden nie ueberschrieben +- Ergebnis-Toast meldet Erfolg/Fehler pro Policy + +> Beim Anlegen entfernt der Import instanzgebundene / schreibgeschuetzte Felder +> (`id`, Zeitstempel, `version`, Zuweisungen, Status). Compliance-Policies +> bekommen bei Bedarf einen Default-`scheduledActionsForRule`-Block, den Graph +> beim Anlegen zwingend verlangt. + +### Benoetigt Graph-Berechtigung + +`DeviceManagementConfiguration.ReadWrite.All` (Import) bzw. +`DeviceManagementConfiguration.Read.All` (nur Export). Der Scope wird beim Start +automatisch zur Verbindung ergaenzt — in der Azure-App-Registration muss die +Berechtigung aber vorhanden und (Admin-)zugestimmt sein. + +--- + ## Architektur ``` @@ -170,7 +213,8 @@ Intune Manager/ │ ├── Graph.ps1 Microsoft Graph API Helpers │ ├── Server.ps1 HTTP-Server (HttpListener) │ ├── Router.ps1 Request-Routing -│ └── Api.ps1 REST-API-Endpoints +│ ├── Api.ps1 REST-API-Endpoints +│ └── PolicyIO.ps1 Policy Export/Import (Compliance, Config, Settings Catalog) └── www/ ├── index.html SPA-Shell ├── styles.css Dark Theme, Read-Only-Regeln @@ -204,8 +248,15 @@ Intune Manager/ | GET | `/api/membership?targets=&groupId=` | Read | Mitgliedschafts-Status pruefen | | POST | `/api/membership/bulk` | Read | Mehrere Mitgliedschaften pruefen | | POST | `/api/assignments/apply` | **Write** | Geplante Zuweisungen ausfuehren | +| GET | `/api/policies/settingscatalog` | Read+Cfg | Settings-Catalog-Policies auflisten | +| GET | `/api/policies/compliance` | Read+Cfg | Compliance-Policies auflisten | +| GET | `/api/policies/configuration` | Read+Cfg | Konfigurationsprofile auflisten | +| POST | `/api/policies/export` | Read+Cfg | Ausgewaehlte Policies als JSON exportieren | +| GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten | +| POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren | > **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All` +> **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import) --- diff --git a/Start.ps1 b/Start.ps1 index 063bbfb..45ba0fc 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -136,6 +136,7 @@ if (-not $SkipModuleCheck) { . (Join-Path $root "src/Models.ps1") . (Join-Path $root "src/Graph.ps1") . (Join-Path $root "src/Api.ps1") +. (Join-Path $root "src/PolicyIO.ps1") . (Join-Path $root "src/Router.ps1") . (Join-Path $root "src/Server.ps1") @@ -161,6 +162,16 @@ if (-not (Test-Path $script:Config.ReportDir)) { New-Item -ItemType Directory -Path $script:Config.ReportDir -Force | Out-Null } +# Import/Export von Compliance Policies & Configuration Profiles benoetigt den +# Scope DeviceManagementConfiguration.* — bei Bedarf ergaenzen, damit das +# Verbindungs-Token die Policies lesen/schreiben darf. +if ($script:Config.Scopes -notcontains 'DeviceManagementConfiguration.ReadWrite.All') { + $script:Config.Scopes = @($script:Config.Scopes) + 'DeviceManagementConfiguration.ReadWrite.All' +} +if ($script:Config.ScopesRo -notcontains 'DeviceManagementConfiguration.Read.All') { + $script:Config.ScopesRo = @($script:Config.ScopesRo) + 'DeviceManagementConfiguration.Read.All' +} + # Session-State (im PowerShell-Prozess gehalten) $script:State = [pscustomobject]@{ Connected = $false @@ -175,7 +186,7 @@ $script:State = [pscustomobject]@{ Session = @() # geplante Zuweisungen } -$script:ToolVersion = "0.1.23" +$script:ToolVersion = "0.1.25" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "" diff --git a/installer/build-local.ps1 b/installer/build-local.ps1 index afbf0ab..eac5d0d 100644 --- a/installer/build-local.ps1 +++ b/installer/build-local.ps1 @@ -1,12 +1,26 @@ Set-StrictMode -Off $ErrorActionPreference = 'Stop' -$env:DOTNET_ROOT = "C:\Program Files\dotnet" +# WiX 4 laeuft als x64-.NET-App. Auf ARM64-Windows (Parallels) liegt unter +# C:\Program Files\dotnet nur die ARM64-Runtime — dann eine separat installierte +# x64-Runtime bevorzugen, sonst faellt wix.exe mit "You must install .NET" aus. +$x64Dotnet = "$env:USERPROFILE\dotnet-x64-install" +if (Test-Path "$x64Dotnet\dotnet.exe") { + $env:DOTNET_ROOT_X64 = $x64Dotnet + $env:DOTNET_ROOT = $x64Dotnet +} else { + $env:DOTNET_ROOT = "C:\Program Files\dotnet" +} + +$version = "0.1.25" $src = "\\Mac\Home\ClaudePRJ\Intune Manager" $stage = "$env:TEMP\IntuneManagerStage" $installerDir = "$src\installer" $harvestWxs = "$env:TEMP\AppFiles.wxs" -$msi = "$env:USERPROFILE\Desktop\IntuneManager-v0.1.24.msi" +# Echter Desktop-Pfad (auf Parallels ist der sichtbare Desktop der Mac-Desktop, +# C:\Mac\Home\Desktop — NICHT C:\Users\\Desktop). +$desktop = [Environment]::GetFolderPath('Desktop') +$msi = "$desktop\IntuneManager-v$version.msi" # --- WixUI Extension installieren (Version passend zu WiX 4.0.6) --- Write-Host "=== Extension ===" -ForegroundColor Cyan @@ -61,7 +75,7 @@ Write-Host "Fragment: $harvestWxs ($idx Dateien)" Write-Host "`n=== MSI bauen ===" -ForegroundColor Cyan wix build "$installerDir\Intune-Manager.wxs" $harvestWxs ` -ext WixToolset.UI.wixext ` - -d Version=0.1.24.0 ` + -d Version=$version.0 ` -d SourceDir=$stage ` -d InstallerDir=$installerDir ` -o $msi diff --git a/src/Api.ps1 b/src/Api.ps1 index 74bfa05..36c6861 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -46,6 +46,13 @@ function Invoke-ApiHandler { "POST /api/membership/bulk" { return Get-MembershipBulkEndpoint -Body $Body } "POST /api/assignments/apply" { return Invoke-ApplyEndpoint -Body $Body } + + "GET /api/policies/compliance" { return Get-CompliancePoliciesEndpoint } + "GET /api/policies/configuration" { return Get-ConfigurationProfilesEndpoint } + "GET /api/policies/settingscatalog" { return Get-SettingsCatalogPoliciesEndpoint } + "POST /api/policies/export" { return Export-PoliciesEndpoint -Body $Body } + "GET /api/policies/exports" { return Get-PolicyExportsEndpoint } + "POST /api/policies/import" { return Import-PoliciesEndpoint -Body $Body } } # 2-segment fallbacks (z.B. /api/groups//members) @@ -165,6 +172,15 @@ function Sync-ConfigFromSettings { $script:Config.ClientIdRo = $script:Settings.connection.clientIdRo $script:Config.Scopes = @($script:Settings.connection.scopes) $script:Config.ScopesRo = @($script:Settings.connection.scopesRo) + # Policy Export/Import braucht den Configuration-Scope. Immer sicherstellen — + # sonst faellt er nach einem Settings-Save (der Config.Scopes neu aus den + # persistierten Settings setzt) bis zum Neustart weg. + if ($script:Config.Scopes -notcontains 'DeviceManagementConfiguration.ReadWrite.All') { + $script:Config.Scopes = @($script:Config.Scopes) + 'DeviceManagementConfiguration.ReadWrite.All' + } + if ($script:Config.ScopesRo -notcontains 'DeviceManagementConfiguration.Read.All') { + $script:Config.ScopesRo = @($script:Config.ScopesRo) + 'DeviceManagementConfiguration.Read.All' + } } function Save-SettingsEndpoint { diff --git a/src/PolicyIO.ps1 b/src/PolicyIO.ps1 new file mode 100644 index 0000000..2022250 --- /dev/null +++ b/src/PolicyIO.ps1 @@ -0,0 +1,427 @@ +# Import/Export von Intune-Policies. +# Unterstuetzte Typen: Compliance Policies, Configuration Profiles (Templates) +# und Settings Catalog. Nutzt die bestehende Microsoft-Graph-Verbindung +# (Connect-MgGraph via Api.ps1). +# +# Zwei Export-Wege, beide aus derselben Aktion: +# 1. Browser-Download — der Endpoint liefert die Export-Objekte im Response, +# das Frontend laedt sie als JSON-Datei(en) herunter (einzeln oder Bundle). +# 2. Server-Archiv — zusaetzlich als JSON unter +# %APPDATA%\IntuneAppManager-Web\policy-exports abgelegt. +# +# Import erfolgt immer als *Neuanlage* im aktuell verbundenen Tenant — es wird +# nie eine bestehende Policy ueberschrieben. Quelle ist entweder hochgeladener +# JSON-Inhalt (Frontend-Upload) oder eine Datei aus dem Server-Archiv. + +function Get-PolicyExportDir { + $dir = Join-Path $env:APPDATA 'IntuneAppManager-Web' + $dir = Join-Path $dir 'policy-exports' + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + return $dir +} + +# Beim Import zu entfernende, schreibgeschuetzte / instanzgebundene Felder. +# Gilt fuer alle Typen; Felder die ein Typ gar nicht besitzt werden ignoriert. +$script:PolicyReadOnlyProps = @( + 'id', 'createdDateTime', 'lastModifiedDateTime', 'version', + 'assignments', 'deviceStatuses', 'userStatuses', + 'deviceStatusOverview', 'userStatusOverview', + 'deviceSettingStateSummaries', 'supportsScopeTags', + 'roleScopeTagIds', + # Settings-Catalog-spezifische Read-Only-/Zaehl-Felder: + 'settingCount', 'creationSource', 'isAssigned', 'priorityMetaData' +) + +# Zentrale Typ-Konfiguration: alles Typ-Spezifische an einer Stelle. +function Get-PolicyTypeConfig { + param([string]$Type) + switch (([string]$Type).ToLower()) { + 'compliance' { + return @{ + Key = 'compliance' + Collection = 'deviceCompliancePolicies' + NameField = 'displayName' + # scheduledActionConfigurations muss mit-exportiert werden, sonst + # laesst sich die Policy spaeter nicht wieder anlegen. + ExportExpand = 'scheduledActionsForRule($expand=scheduledActionConfigurations)' + ExportType = 'CompliancePolicy' + FilePrefix = 'CompliancePolicy' + Label = 'Compliance' + } + } + 'configuration' { + return @{ + Key = 'configuration' + Collection = 'deviceConfigurations' + NameField = 'displayName' + ExportExpand = $null + ExportType = 'ConfigurationProfile' + FilePrefix = 'ConfigProfile' + Label = 'Konfigurationsprofil' + } + } + 'settingscatalog' { + return @{ + Key = 'settingscatalog' + Collection = 'configurationPolicies' + # Settings Catalog nutzt 'name' statt 'displayName'. + NameField = 'name' + # Die eigentliche Konfiguration steckt in der 'settings'-Nav-Property. + ExportExpand = 'settings' + ExportType = 'SettingsCatalog' + FilePrefix = 'SettingsCatalog' + Label = 'Settings Catalog' + } + } + default { return $null } + } +} + +# ExportType (aus Datei/Envelope) -> Typ-Config. Reverse-Lookup fuer den Import. +function Get-PolicyTypeConfigByExportType { + param([string]$ExportType) + foreach ($key in @('compliance','configuration','settingscatalog')) { + $cfg = Get-PolicyTypeConfig $key + if ($cfg.ExportType -eq $ExportType) { return $cfg } + } + return $null +} + +function Assert-GraphConnected { + if (-not $script:State.Connected) { throw 'NOT_CONNECTED' } +} + +# Property-Zugriff, der sowohl Hashtable (PS7 -AsHashtable) als auch +# PSCustomObject (PS5.1) korrekt bedient. +function Get-PolicyProp { + param($Obj, [string]$Name) + if ($null -eq $Obj) { return $null } + if ($Obj -is [System.Collections.IDictionary]) { + if ($Obj.Contains($Name)) { return $Obj[$Name] } + return $null + } + $p = $Obj.PSObject.Properties[$Name] + if ($p) { return $p.Value } + return $null +} + +# Grobe Plattform-Bezeichnung fuer die Listenanzeige. +function Get-PolicyPlatformLabel { + param($Raw, [string]$Type) + if (([string]$Type).ToLower() -eq 'settingscatalog') { + $p = Get-PolicyProp $Raw 'platforms' + return [string]$p + } + $t = [string](Get-PolicyProp $Raw '@odata.type') + switch -Regex ($t) { + 'windows' { return 'Windows' } + 'macOS|mac' { return 'macOS' } + 'ios' { return 'iOS' } + 'android' { return 'Android' } + default { return '' } + } +} + +# ── Graph-Zugriffe ── + +# Liste (nur Metadaten) eines Typs, normalisiert fuer das Frontend. +function Get-GraphPolicyList { + param([Parameter(Mandatory=$true)][string]$Type) + $cfg = Get-PolicyTypeConfig $Type + if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" } + $raw = Get-GraphPaged -Uri "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)" + $items = @() + foreach ($r in $raw) { + $id = Get-PolicyProp $r 'id' + if (-not $id) { continue } + $name = Get-PolicyProp $r $cfg.NameField + if (-not $name) { $name = Get-PolicyProp $r 'displayName' } + if (-not $name) { $name = Get-PolicyProp $r 'name' } + $items += [ordered]@{ + id = [string]$id + name = [string]$name + type = $cfg.Key + typeLabel = $cfg.Label + platform = Get-PolicyPlatformLabel -Raw $r -Type $cfg.Key + odataType = [string](Get-PolicyProp $r '@odata.type') + lastModifiedDateTime = Get-PolicyProp $r 'lastModifiedDateTime' + } + } + return $items +} + +# Vollstaendige Policy inkl. Settings/Detail — Grundlage fuer den Export. +function Get-GraphPolicyDetail { + param( + [Parameter(Mandatory=$true)][string]$Type, + [Parameter(Mandatory=$true)][string]$Id + ) + $cfg = Get-PolicyTypeConfig $Type + if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" } + $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id" + if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" } + return Invoke-MgGraphRequestRetry -Uri $uri -Method GET +} + +# PSCustomObject/Hashtable -> bereinigte Hashtable ohne Read-Only-Felder. +function ConvertTo-ImportBody { + param([Parameter(Mandatory=$true)]$Policy) + $body = @{} + $props = if ($Policy -is [System.Collections.IDictionary]) { + $Policy.Keys | ForEach-Object { [pscustomobject]@{ Name = $_; Value = $Policy[$_] } } + } else { + $Policy.PSObject.Properties + } + foreach ($p in $props) { + if ($p.Name -in $script:PolicyReadOnlyProps) { continue } + # OData-Metadaten aus dem Export nie mitschicken. + if ($p.Name -like '*@odata.context') { continue } + # scheduledActionsForRule enthaelt selbst Read-Only-Ids -> separat saeubern. + if ($p.Name -eq 'scheduledActionsForRule') { continue } + $body[$p.Name] = $p.Value + } + return $body +} + +function New-DefaultComplianceScheduledActions { + # Compliance Policies verlangen beim Anlegen mindestens einen + # scheduledActionsForRule-Block, sonst antwortet Graph mit 400. + return @( + @{ + ruleName = 'PasswordRequired' + scheduledActionConfigurations = @( + @{ + actionType = 'block' + gracePeriodHours = 0 + notificationTemplateId = '00000000-0000-0000-0000-000000000000' + notificationMessageCCList = @() + } + ) + } + ) +} + +function Import-GraphCompliancePolicy { + param([Parameter(Mandatory=$true)]$Policy) + $body = ConvertTo-ImportBody -Policy $Policy + + # scheduledActionsForRule aus dem Export uebernehmen (Ids strippen) oder Default. + $sched = Get-PolicyProp $Policy 'scheduledActionsForRule' + $cleanSched = @() + foreach ($rule in @($sched)) { + if (-not $rule) { continue } + $cfgs = @() + foreach ($c in @(Get-PolicyProp $rule 'scheduledActionConfigurations')) { + if (-not $c) { continue } + $tpl = Get-PolicyProp $c 'notificationTemplateId' + $cfgs += @{ + actionType = [string](Get-PolicyProp $c 'actionType') + gracePeriodHours = [int](Get-PolicyProp $c 'gracePeriodHours') + notificationTemplateId = if ($tpl) { [string]$tpl } else { '00000000-0000-0000-0000-000000000000' } + notificationMessageCCList = @(Get-PolicyProp $c 'notificationMessageCCList') + } + } + $cleanSched += @{ ruleName = [string](Get-PolicyProp $rule 'ruleName'); scheduledActionConfigurations = $cfgs } + } + if ($cleanSched.Count -eq 0) { $cleanSched = New-DefaultComplianceScheduledActions } + $body['scheduledActionsForRule'] = $cleanSched + + $json = $body | ConvertTo-Json -Depth 50 + return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies' -Method POST -Body $json -ContentType 'application/json' +} + +function Import-GraphConfigurationProfile { + param([Parameter(Mandatory=$true)]$Policy) + $body = ConvertTo-ImportBody -Policy $Policy + if (-not $body['@odata.type']) { + throw 'Configuration Profile benoetigt @odata.type fuer den Import.' + } + $json = $body | ConvertTo-Json -Depth 50 + return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' -Method POST -Body $json -ContentType 'application/json' +} + +function Import-GraphSettingsCatalogPolicy { + param([Parameter(Mandatory=$true)]$Policy) + $body = ConvertTo-ImportBody -Policy $Policy + if (-not $body['name']) { + throw 'Settings-Catalog-Policy benoetigt ein "name"-Feld fuer den Import.' + } + # 'settings' MUSS mitgeschickt werden — kommt aus dem $expand=settings-Export. + if (-not $body.ContainsKey('settings')) { $body['settings'] = @() } + $json = $body | ConvertTo-Json -Depth 50 + return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' +} + +# Dispatcht anhand des ExportType auf den passenden Import. +function Import-GraphPolicyByExportType { + param([string]$ExportType, $Policy) + switch ($ExportType) { + 'CompliancePolicy' { return Import-GraphCompliancePolicy -Policy $Policy } + 'ConfigurationProfile' { return Import-GraphConfigurationProfile -Policy $Policy } + 'SettingsCatalog' { return Import-GraphSettingsCatalogPolicy -Policy $Policy } + default { throw "Unbekannter exportType: $ExportType" } + } +} + +# Anzeigename einer (evtl. Settings-Catalog-)Policy ermitteln. +function Get-PolicyDisplayName { + param($Policy) + $n = Get-PolicyProp $Policy 'displayName' + if (-not $n) { $n = Get-PolicyProp $Policy 'name' } + return [string]$n +} + +# ── Endpoints ── + +function Get-CompliancePoliciesEndpoint { + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'compliance') } +} + +function Get-ConfigurationProfilesEndpoint { + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'configuration') } +} + +function Get-SettingsCatalogPoliciesEndpoint { + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'settingscatalog') } +} + +# Export: liefert die Export-Objekte im Response (Browser-Download) UND legt sie +# zusaetzlich als JSON im Server-Archiv ab. Body: { type, ids }. +function Export-PoliciesEndpoint { + param($Body) + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + + $type = [string](Get-PolicyProp $Body 'type') + $cfg = Get-PolicyTypeConfig $type + if (-not $cfg) { return @{ __status = 400; error = "Unbekannter Typ: $type" } } + + $ids = @(Get-PolicyProp $Body 'ids') + if ($ids.Count -eq 0) { return @{ __status = 400; error = 'Keine Policies ausgewaehlt' } } + + $exportDir = Get-PolicyExportDir + $sourceTenant = if ($script:State.TenantId) { [string]$script:State.TenantId } else { 'unknown' } + $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' + + $files = @() + $exports = @() + foreach ($id in $ids) { + $policy = $null + try { $policy = Get-GraphPolicyDetail -Type $type -Id ([string]$id) } catch {} + if (-not $policy) { continue } + + $displayName = Get-PolicyDisplayName $policy + $exportData = [ordered]@{ + exportType = $cfg.ExportType + exportDate = (Get-Date).ToString('o') + sourceTenant = $sourceTenant + policyName = $displayName + policy = $policy + } + + $safeName = ($displayName) -replace '[^\w\-\.]', '_' + if (-not $safeName) { $safeName = [string]$id } + $fileName = "$($cfg.FilePrefix)_${safeName}_$stamp.json" + $filePath = Join-Path $exportDir $fileName + try { + $exportData | ConvertTo-Json -Depth 50 | Set-Content -Path $filePath -Encoding UTF8 + $files += $fileName + } catch {} + + # Fuer den Browser-Download inkl. Dateinamens-Vorschlag. + $exports += @{ + fileName = $fileName + policyName = $displayName + data = $exportData + } + } + + return @{ ok = $true; exportedCount = $exports.Count; files = @($files); exports = @($exports) } +} + +# Liste des Server-Archivs (fuer optionalen Server-seitigen Re-Import). +function Get-PolicyExportsEndpoint { + $exportDir = Get-PolicyExportDir + $files = @() + if (Test-Path $exportDir) { + $files = Get-ChildItem -Path $exportDir -Filter '*.json' | Sort-Object LastWriteTime -Descending | ForEach-Object { + $content = $null + try { $content = Get-Content $_.FullName -Raw | ConvertFrom-Json } catch {} + @{ + fileName = $_.Name + exportType = if ($content) { [string]$content.exportType } else { '' } + exportDate = if ($content) { $content.exportDate } else { $null } + sourceTenant = if ($content) { [string]$content.sourceTenant } else { '' } + policyName = if ($content) { [string](Get-PolicyProp $content 'policyName') } else { $_.Name } + } + } + } + return @{ ok = $true; items = @($files) } +} + +# Import: legt Policies als Neuanlage an. Quelle: +# Body.policies = [ { exportType, policy }, ... ] (Frontend-Upload) ODER +# Body.fileNames = [ ".json", ... ] (Server-Archiv) +function Import-PoliciesEndpoint { + param($Body) + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + + $uploaded = @(Get-PolicyProp $Body 'policies') + $fileNames = @(Get-PolicyProp $Body 'fileNames') + + if ($uploaded.Count -eq 0 -and $fileNames.Count -eq 0) { + return @{ __status = 400; error = 'Keine Policies zum Importieren uebergeben' } + } + + $results = @() + + # 1) Hochgeladene Envelopes + foreach ($env in $uploaded) { + if (-not $env) { continue } + $exportType = [string](Get-PolicyProp $env 'exportType') + $policy = Get-PolicyProp $env 'policy' + $policyName = Get-PolicyProp $env 'policyName' + if (-not $policyName) { $policyName = Get-PolicyDisplayName $policy } + if (-not $policy) { + $results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' } + continue + } + try { + $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy + $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } + } catch { + $msg = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} + $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $false; error = $msg } + } + } + + # 2) Dateien aus dem Server-Archiv + $exportDir = Get-PolicyExportDir + foreach ($fileName in $fileNames) { + $safe = ($fileName -replace '[\\/]', '') + $filePath = Join-Path $exportDir $safe + if (-not (Test-Path $filePath)) { + $results += @{ fileName = $fileName; success = $false; error = 'Datei nicht gefunden' } + continue + } + $policyName = $fileName + try { + $content = Get-Content $filePath -Raw | ConvertFrom-Json + $exportType = [string](Get-PolicyProp $content 'exportType') + $policy = Get-PolicyProp $content 'policy' + $policyName = Get-PolicyDisplayName $policy + $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy + $results += @{ fileName = $fileName; policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } + } catch { + $msg = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} + $results += @{ fileName = $fileName; policyName = [string]$policyName; success = $false; error = $msg } + } + } + + $ok = @($results | Where-Object { $_.success }).Count + return @{ ok = $true; importedCount = $ok; results = @($results) } +} diff --git a/www/app.js b/www/app.js index 9ae5a54..84664de 100644 --- a/www/app.js +++ b/www/app.js @@ -4391,6 +4391,7 @@ function switchMainView(view) { const groupView = document.getElementById('groupMgmtView'); const reportView = document.getElementById('appReportView'); const devView = document.getElementById('devView'); + const polView = document.getElementById('policiesView'); document.querySelectorAll('#topbarNav .nav-tab').forEach(t => { t.classList.toggle('active', t.dataset.view === view); t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false'); @@ -4399,12 +4400,13 @@ function switchMainView(view) { groupView .classList.toggle('hidden', view !== 'groups'); reportView.classList.toggle('hidden', view !== 'report'); devView .classList.toggle('hidden', view !== 'devices'); + polView .classList.toggle('hidden', view !== 'policies'); } document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { btn.addEventListener('click', () => { const view = btn.dataset.view; - if ((view === 'groups' || view === 'report' || view === 'devices') && !State.connected) { + if ((view === 'groups' || view === 'report' || view === 'devices' || view === 'policies') && !State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } @@ -4412,6 +4414,7 @@ document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { if (view === 'groups') openGroupExportModal(); if (view === 'report' && ReportState.items.length === 0) loadAppReport(); if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); } + if (view === 'policies' && PolState.items.length === 0) loadPolicies(); }); }); @@ -5509,3 +5512,242 @@ document.querySelectorAll('#devTable th.sortable').forEach(th => { devSort(); }); }); + +// ============================================================= +// Policies (Export / Import) +// ============================================================= +const PolState = { + items: [], // alle geladenen Policies (normalisiert) + selected: new Set(), // Keys "type::id" + sortCol: 'name', + sortAsc: true, + search: '', + typeFilter: '', +}; + +function polKey(p) { return `${p.type}::${p.id}`; } + +function polFmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d)) return '—'; + return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' }); +} + +async function loadPolicies() { + const body = document.getElementById('polBody'); + body.innerHTML = ` Lade Policies…`; + // Drei Typen parallel laden; ein fehlschlagender Typ (z.B. fehlender Scope) + // soll die anderen nicht blockieren. + const endpoints = [ + ['/api/policies/settingscatalog', 'Settings Catalog'], + ['/api/policies/compliance', 'Compliance'], + ['/api/policies/configuration', 'Konfigurationsprofile'], + ]; + const settled = await Promise.allSettled(endpoints.map(([url]) => api(url))); + let items = []; + const errors = []; + settled.forEach((r, i) => { + if (r.status === 'fulfilled') { + items = items.concat(r.value.items || []); + } else { + errors.push(`${endpoints[i][1]}: ${r.reason?.message || 'Fehler'}`); + } + }); + PolState.items = items; + // Auswahl auf noch existierende Policies eindampfen + const valid = new Set(items.map(polKey)); + PolState.selected = new Set([...PolState.selected].filter(k => valid.has(k))); + renderPolicies(); + if (errors.length) toast('Einige Typen konnten nicht geladen werden. ' + errors.join(' · '), 'warn'); +} + +function polFiltered() { + const q = PolState.search.toLowerCase(); + const tf = PolState.typeFilter; + let list = PolState.items.filter(p => { + if (tf && p.type !== tf) return false; + if (!q) return true; + return (p.name || '').toLowerCase().includes(q) + || (p.typeLabel || '').toLowerCase().includes(q) + || (p.platform || '').toLowerCase().includes(q); + }); + const col = PolState.sortCol, asc = PolState.sortAsc ? 1 : -1; + list.sort((a, b) => String(a[col] ?? '').localeCompare(String(b[col] ?? ''), 'de', { numeric: true }) * asc); + return list; +} + +function renderPolicies() { + const body = document.getElementById('polBody'); + const list = polFiltered(); + document.getElementById('polCount').textContent = list.length; + if (!list.length) { + body.innerHTML = `${PolState.items.length ? 'Keine Policy passt zum Filter.' : 'Keine Policies gefunden.'}`; + polUpdateSelCount(); + return; + } + body.innerHTML = list.map(p => { + const key = polKey(p); + const checked = PolState.selected.has(key) ? 'checked' : ''; + return ` + + ${escapeHtml(p.name || '—')} + ${escapeHtml(p.typeLabel || p.type)} + ${escapeHtml(p.platform || '—')} + ${polFmtDate(p.lastModifiedDateTime)} + `; + }).join(''); + body.querySelectorAll('.pol-row-check').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) PolState.selected.add(cb.dataset.key); + else PolState.selected.delete(cb.dataset.key); + polUpdateSelCount(); + polSyncCheckAll(); + }); + }); + // Klick auf Zeile toggelt die Checkbox (außer direkt auf die Checkbox) + body.querySelectorAll('tr[data-key]').forEach(tr => { + tr.addEventListener('click', e => { + if (e.target.classList.contains('pol-row-check')) return; + const cb = tr.querySelector('.pol-row-check'); + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + }); + }); + polUpdateSelCount(); + polSyncCheckAll(); +} + +function polUpdateSelCount() { + const n = PolState.selected.size; + document.getElementById('polSelCount').textContent = n; + document.getElementById('btnPolExport').disabled = n === 0; +} + +function polSyncCheckAll() { + const visible = polFiltered().map(polKey); + const all = visible.length > 0 && visible.every(k => PolState.selected.has(k)); + const some = visible.some(k => PolState.selected.has(k)); + const cb = document.getElementById('polCheckAll'); + cb.checked = all; + cb.indeterminate = !all && some; +} + +function polDownloadJson(obj, filename) { + const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +async function polExportSelected() { + const sel = [...PolState.selected]; + if (!sel.length) return; + const byType = {}; + for (const key of sel) { + const idx = key.indexOf('::'); + const type = key.slice(0, idx), id = key.slice(idx + 2); + (byType[type] ||= []).push(id); + } + setLoading('Exportiere Policies…'); + try { + let allExports = []; + for (const [type, ids] of Object.entries(byType)) { + const res = await api('/api/policies/export', { method: 'POST', body: { type, ids }, timeoutMs: 120000 }); + if (res.exports) allExports = allExports.concat(res.exports); + } + if (!allExports.length) { toast('Nichts exportiert.', 'warn'); return; } + if (allExports.length === 1) { + polDownloadJson(allExports[0].data, allExports[0].fileName || 'policy.json'); + } else { + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '').replace(/-/g, ''); + const bundle = { + type: 'IntuneManagerPolicyBundle', + exportDate: new Date().toISOString(), + count: allExports.length, + policies: allExports.map(e => e.data), + }; + polDownloadJson(bundle, `intune-policies_${stamp}.json`); + } + toast(`${allExports.length} Policy(s) exportiert (auch im Server-Archiv abgelegt).`, 'ok'); + } catch (e) { + toast('Export fehlgeschlagen: ' + e.message, 'err'); + } finally { + clearLoading(); + } +} + +// Datei-Inhalt -> Liste von Envelopes { exportType, policy, ... } +function polExtractEnvelopes(parsed, fileName) { + if (parsed && Array.isArray(parsed.policies)) return parsed.policies; + if (parsed && parsed.exportType && parsed.policy) return [parsed]; + if (parsed && parsed.policy) return [parsed]; // nachsichtig + toast(`${fileName}: unbekanntes Policy-Format.`, 'err'); + return []; +} + +async function polHandleImportFiles(fileList) { + const files = [...fileList]; + if (!files.length) return; + let envelopes = []; + for (const f of files) { + let parsed = null; + try { parsed = JSON.parse(await f.text()); } + catch { toast(`${f.name}: kein gültiges JSON.`, 'err'); continue; } + envelopes = envelopes.concat(polExtractEnvelopes(parsed, f.name)); + } + if (!envelopes.length) return; + if (!window.confirm(`${envelopes.length} Policy(s) als NEUE Policies im verbundenen Tenant anlegen?\n\nBestehende Policies werden dabei nicht verändert.`)) return; + setLoading('Importiere Policies…'); + try { + const res = await api('/api/policies/import', { method: 'POST', body: { policies: envelopes }, timeoutMs: 180000 }); + const results = res.results || []; + const ok = results.filter(r => r.success); + const fail = results.filter(r => !r.success); + if (fail.length) { + const first = fail[0]; + toast(`${ok.length} importiert, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Import'); + } else { + toast(`${ok.length} Policy(s) importiert.`, 'ok', 'Import'); + } + await loadPolicies(); + } catch (e) { + toast('Import fehlgeschlagen: ' + e.message, 'err'); + } finally { + clearLoading(); + } +} + +// --- Policies: Event-Wiring --- +document.getElementById('btnPolRefresh')?.addEventListener('click', loadPolicies); +document.getElementById('btnPolExport')?.addEventListener('click', polExportSelected); +document.getElementById('btnPolImport')?.addEventListener('click', () => document.getElementById('polImportFile').click()); +document.getElementById('polImportFile')?.addEventListener('change', e => { + polHandleImportFiles(e.target.files); + e.target.value = ''; // erlaubt erneuten Import derselben Datei +}); +document.getElementById('polCheckAll')?.addEventListener('change', e => { + const visible = polFiltered().map(polKey); + if (e.target.checked) visible.forEach(k => PolState.selected.add(k)); + else visible.forEach(k => PolState.selected.delete(k)); + renderPolicies(); +}); +let _polSearchTimer = null; +document.getElementById('polSearch')?.addEventListener('input', e => { + clearTimeout(_polSearchTimer); + _polSearchTimer = setTimeout(() => { PolState.search = e.target.value.trim(); renderPolicies(); }, 200); +}); +document.getElementById('polTypeFilter')?.addEventListener('change', e => { + PolState.typeFilter = e.target.value; + renderPolicies(); +}); +document.querySelectorAll('#polTable th.sortable').forEach(th => { + th.addEventListener('click', () => { + if (PolState.sortCol === th.dataset.col) PolState.sortAsc = !PolState.sortAsc; + else { PolState.sortCol = th.dataset.col; PolState.sortAsc = true; } + renderPolicies(); + }); +}); diff --git a/www/index.html b/www/index.html index 4dee3c9..4310daa 100644 --- a/www/index.html +++ b/www/index.html @@ -36,6 +36,7 @@ +
@@ -558,6 +559,53 @@
+ + +