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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:25:29 +02:00
co-authored by Claude Opus 4.8
parent e63ae28cb1
commit 8393526d8a
9 changed files with 898 additions and 7 deletions
+15
View File
@@ -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.
+52 -1
View File
@@ -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_<Datum>.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)
---
+12 -1
View File
@@ -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 ""
+17 -3
View File
@@ -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\<user>\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
+16
View File
@@ -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/<id>/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 {
+427
View File
@@ -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 = [ "<datei>.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) }
}
+243 -1
View File
@@ -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 = `<tr><td colspan="5" class="pol-hint"><span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> Lade Policies…</td></tr>`;
// 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 = `<tr><td colspan="5" class="pol-empty">${PolState.items.length ? 'Keine Policy passt zum Filter.' : 'Keine Policies gefunden.'}</td></tr>`;
polUpdateSelCount();
return;
}
body.innerHTML = list.map(p => {
const key = polKey(p);
const checked = PolState.selected.has(key) ? 'checked' : '';
return `<tr data-key="${escapeHtml(key)}">
<td class="pol-col-check"><input type="checkbox" class="pol-row-check" data-key="${escapeHtml(key)}" ${checked}></td>
<td class="pol-name" title="${escapeHtml(p.name || '')}">${escapeHtml(p.name || '—')}</td>
<td><span class="pol-type-badge">${escapeHtml(p.typeLabel || p.type)}</span></td>
<td>${escapeHtml(p.platform || '—')}</td>
<td>${polFmtDate(p.lastModifiedDateTime)}</td>
</tr>`;
}).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();
});
});
+48
View File
@@ -36,6 +36,7 @@
<button class="nav-tab" data-view="groups" aria-selected="false">Group Management</button>
<button class="nav-tab" data-view="report" aria-selected="false">App Report</button>
<button class="nav-tab" data-view="devices" aria-selected="false">Geräte</button>
<button class="nav-tab" data-view="policies" aria-selected="false">Policies</button>
</nav>
<div class="header-actions">
@@ -558,6 +559,53 @@
</div>
</section>
<!-- POLICIES VIEW -->
<section class="pol-view hidden" id="policiesView">
<div class="pol-toolbar">
<div class="search-wrap">
<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="polSearch" class="input input-search" placeholder="Policy suchen…" autocomplete="off">
</div>
<span id="polCount" class="count-badge">0</span>
<select id="polTypeFilter" class="input" style="height:34px;max-width:220px;">
<option value="">Alle Typen</option>
<option value="settingscatalog">Settings Catalog</option>
<option value="compliance">Compliance</option>
<option value="configuration">Konfigurationsprofil</option>
</select>
<span class="pol-toolbar-spacer" style="flex:1;"></span>
<button class="btn btn-secondary btn-sm" id="btnPolRefresh" title="Policies neu laden">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
Neu laden
</button>
<button class="btn btn-secondary btn-sm pol-write" id="btnPolImport" title="Policies aus JSON-Datei(en) importieren (Neuanlage)">
<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="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import…
</button>
<input type="file" id="polImportFile" accept=".json,application/json" multiple style="display:none;">
<button class="btn btn-primary btn-sm" id="btnPolExport" disabled title="Ausgewählte Policies als JSON exportieren">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export (<span id="polSelCount">0</span>)
</button>
</div>
<div class="pol-table-wrap">
<table class="pol-table" id="polTable">
<thead>
<tr>
<th class="pol-col-check"><input type="checkbox" id="polCheckAll" title="Alle auswählen"></th>
<th class="sortable" data-col="name">Name <span class="sort-ico"></span></th>
<th class="sortable" data-col="typeLabel">Typ <span class="sort-ico"></span></th>
<th class="sortable" data-col="platform">Plattform <span class="sort-ico"></span></th>
<th class="sortable" data-col="lastModifiedDateTime">Geändert <span class="sort-ico"></span></th>
</tr>
</thead>
<tbody id="polBody">
<tr><td colspan="5" class="pol-hint">Klicke auf „Neu laden" um Policies zu laden.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- MODAL: Wipe -->
<div class="modal-backdrop hidden" id="wipeModalBackdrop">
<div class="modal" style="max-width:420px;">
+68 -1
View File
@@ -4872,7 +4872,8 @@ body.read-only #btnApply,
body.read-only .gex-add-pane,
body.read-only .gex-tab[data-tab="add"],
body.read-only .gex-tab[data-tab="import"],
body.read-only #gexPaneImport {
body.read-only #gexPaneImport,
body.read-only .pol-write {
display: none !important;
}
@@ -5340,3 +5341,69 @@ body.read-only .app-row {
.dev-actions-title { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .04em; }
.dev-action-btns { display: flex; flex-wrap: wrap; gap: 6px; }
.dev-action-btn { display: inline-flex; align-items: center; gap: 5px; }
/* =============================================================
Policies View (Export / Import)
============================================================= */
.pol-view {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
height: calc(100vh - var(--topbar-h, 52px) - 32px);
box-sizing: border-box;
}
.pol-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
flex-wrap: wrap;
}
.pol-toolbar .search-wrap { flex: 0 1 320px; }
.pol-table-wrap {
flex: 1;
overflow: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.pol-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.pol-table thead { position: sticky; top: 0; z-index: 2; background: var(--bg2); }
.pol-table th {
padding: 8px 10px;
text-align: left;
font-weight: 600;
font-size: 12px;
color: var(--text-dim, #888);
border-bottom: 1px solid var(--border);
white-space: nowrap;
user-select: none;
}
.pol-table th.sortable { cursor: pointer; }
.pol-table th.sortable:hover { color: var(--text); background: var(--bg3, var(--hover)); }
.pol-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.pol-table tbody tr:last-child td { border-bottom: none; }
.pol-table tbody tr:hover { background: var(--hover, rgba(255,255,255,0.03)); }
.pol-col-check { width: 34px; text-align: center; }
.pol-table td.pol-col-check { text-align: center; }
.pol-table td.pol-name { font-weight: 500; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pol-type-badge {
display: inline-block;
padding: 1px 8px;
border-radius: var(--r-pill, 999px);
font-size: 11px;
font-weight: 600;
background: var(--bg3, var(--hover));
color: var(--text-dim, #999);
white-space: nowrap;
}
.pol-hint, .pol-empty {
padding: 24px 12px;
text-align: center;
color: var(--text-dim, #888);
font-size: 13px;
}