diff --git a/CHANGELOG.md b/CHANGELOG.md index 5034dc7..56ddb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [Unreleased] + +Policy-Import härter, Administrative Vorlagen, Zuweisung nach Import, Git-Snapshot. + +### Neu + +- **Administrative Vorlagen (ADMX / `groupPolicyConfigurations`)** als vierter Policy-Typ für Export und Import. Beim Import wird die Konfigurations-Hülle angelegt und jede Einstellung einzeln über `definition@odata.bind` / `presentation@odata.bind` angehängt (eingebaute ADMX-Definitionen sind tenantübergreifend gültig). Neuer Endpoint `GET /api/policies/administrativetemplate`. +- **Zuweisung direkt nach Import**: Dialog listet die neu angelegten Policies und lässt **pro Policy Include-/Exclude-Gruppen** im Ziel-Tenant zuweisen (Typeahead über die vorhandene Gruppensuche). Neuer Endpoint `POST /api/policies/assign` (nutzt die typ-spezifische `/assign`-Action für alle vier Typen). +- **Git-Snapshot (versioniertes Policy-Backup)**: Button „Git-Snapshot" schreibt alle Policies aller Typen als JSON in einen konfigurierten lokalen Git-Ordner und committet sie (optional `git push` über den vorhandenen Credential-Helper). Stabile Dateinamen + deterministische, zeitstempel-freie Ausgabe → saubere Diffs. Neue Settings-Sektion `policyBackup` (`gitRepoPath`, `push`). +- Import akzeptiert jetzt auch **Git-Snapshot-Dateien** (Feld `policyType` zusätzlich zu `exportType`). + +### Behoben + +- **Settings-Catalog-Import** war gegen Graph-Schema-Fehler anfällig: verschachtelte Collection-Properties (`children`, `*SettingCollectionValue`) werden jetzt konsequent als Arrays serialisiert (repariert PowerShell-5.1-JSON-Roundtrip-Schäden), `null`-Collections werden zu `[]` (Graph verlangt `Nullable=False`), der Wrapper-`@odata.type` wird je Setting gesetzt und das read-only `id`-Feld entfernt. +- **Phantom-Fehler beim Import**: ein fehlendes `fileNames`-Feld erzeugte über `@($null)` einen Geisterdurchlauf im Archiv-Pfad und meldete fälschlich „1 fehlgeschlagen". +- **Tenant-Wechsel**: das Frontend zeigte weiter die Apps/Gruppen des vorherigen Tenants, weil die Lade-Guards nicht zurückgesetzt wurden. Alle datentragenden Caches werden jetzt beim Wechsel verworfen (`resetClientState`). +- **WAM-Anmeldefenster** kommt beim Login/Tenant-Wechsel zuverlässig in den Vordergrund (AttachThreadInput + kurzer ALT-Tap zum Lösen des Windows-Foreground-Locks). + +--- + ## [0.1.26] - 2026-08-21 Multi-Tenant, Pro-Tenant-Vorgaben, RPA entfernt. diff --git a/README.md b/README.md index e4499d6..5640408 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,21 @@ neu an. Unterstuetzte Typen: automatisch zur Verbindung ergaenzt — in der Azure-App-Registration muss die Berechtigung aber vorhanden und (Admin-)zugestimmt sein. +### Git-Snapshot (versioniertes Backup) + +Button **„Git-Snapshot"** im Policies-Tab schreibt **alle** Policies aller Typen +als JSON in einen konfigurierten **lokalen Git-Ordner** und committet sie +automatisch — ideal als versioniertes Backup mit nachvollziehbarer History. + +- Konfiguration: **Settings → Policy-Backup (Git)** — lokaler Repo-Pfad (wird bei + Bedarf angelegt und `git init`-isiert) + optional **automatischer `git push`** + (nutzt den vorhandenen Git-Credential-Helper; **kein Token in der App**). +- **Stabile Dateinamen** (`//__.json`) und deterministische, + zeitstempel-freie JSON-Ausgabe → saubere Git-Diffs zwischen Snapshots. Entfernte + Policies verschwinden aus dem Snapshot. +- Multi-Tenant: je Mandant ein eigener Unterordner (nach Profil-Label bzw. Tenant-ID). +- Gibt es keine Aenderungen seit dem letzten Snapshot, wird nichts committet. + --- ## Architektur diff --git a/src/Api.ps1 b/src/Api.ps1 index 2e6ed0b..dc0b6b4 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -53,9 +53,13 @@ function Invoke-ApiHandler { "GET /api/policies/compliance" { return Get-CompliancePoliciesEndpoint } "GET /api/policies/configuration" { return Get-ConfigurationProfilesEndpoint } "GET /api/policies/settingscatalog" { return Get-SettingsCatalogPoliciesEndpoint } + "GET /api/policies/administrativetemplate" { return Get-AdministrativeTemplatesEndpoint } "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 } + "POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -Body $Body } + "POST /api/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint } + "POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body } } # 2-segment fallbacks (z.B. /api/groups//members) @@ -928,7 +932,30 @@ public class WinFocusHelper2 { [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId); + [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); + // Holt ein Fenster zuverlaessig in den Vordergrund und umgeht den Windows- + // Foreground-Lock: kurzer ALT-Tap (entsperrt SetForegroundWindow) + Anhaengen + // an den Input-Thread des aktuellen Vordergrundfensters (AttachThreadInput). + public static void ForceForeground(IntPtr hWnd) { + keybd_event(0x12, 0, 0, UIntPtr.Zero); // ALT down -> Foreground-Lock loesen + keybd_event(0x12, 0, 2, UIntPtr.Zero); // ALT up (KEYEVENTF_KEYUP = 2) + IntPtr fore = GetForegroundWindow(); + uint foreThread = GetWindowThreadProcessId(fore, IntPtr.Zero); + uint thisThread = GetCurrentThreadId(); + bool attached = false; + if (foreThread != 0 && foreThread != thisThread) { + attached = AttachThreadInput(foreThread, thisThread, true); + } + ShowWindow(hWnd, 9); // SW_RESTORE + BringWindowToTop(hWnd); + SetForegroundWindow(hWnd); + if (attached) { AttachThreadInput(foreThread, thisThread, false); } + } } '@ -ErrorAction SilentlyContinue $deadline = (Get-Date).AddSeconds(30) @@ -950,9 +977,7 @@ public class WinFocusHelper2 { return $true }, [IntPtr]::Zero) | Out-Null if ($script:found -ne [IntPtr]::Zero) { - [WinFocusHelper2]::ShowWindow($script:found, 9) | Out-Null # SW_RESTORE - [WinFocusHelper2]::BringWindowToTop($script:found) | Out-Null - [WinFocusHelper2]::SetForegroundWindow($script:found) | Out-Null + [WinFocusHelper2]::ForceForeground($script:found) Start-Sleep -Milliseconds 800 } Start-Sleep -Milliseconds 400 @@ -2597,6 +2622,53 @@ function Invoke-FilePickerEndpoint { return @{ ok = $true; path = $path } } +function Show-OpenFolderDialog { + # Wie Show-OpenFileDialog, aber FolderBrowserDialog in einem STA-Prozess. + param([string]$Title = "Ordner auswaehlen") + $tEsc = $Title -replace "'", "''" + $inner = @" +`$ProgressPreference = 'SilentlyContinue' +Add-Type -AssemblyName System.Windows.Forms +`$dlg = New-Object System.Windows.Forms.FolderBrowserDialog +`$dlg.Description = '$tEsc' +`$owner = New-Object System.Windows.Forms.Form +`$owner.TopMost = `$true +`$owner.ShowInTaskbar = `$false +`$owner.WindowState = 'Minimized' +`$owner.Show(); `$owner.Activate() +`$r = `$dlg.ShowDialog(`$owner) +`$owner.Dispose() +if (`$r -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(`$dlg.SelectedPath) } +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner)) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = "powershell.exe" + $psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -EncodedCommand $encoded" + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.CreateNoWindow = $true + try { + $proc = [System.Diagnostics.Process]::Start($psi) + $path = $proc.StandardOutput.ReadToEnd() + $proc.WaitForExit() + } catch { + throw "Ordner-Dialog-Prozess konnte nicht gestartet werden: $($_.Exception.Message)" + } + return ([string]$path).Trim() +} + +function Invoke-FolderPickerEndpoint { + param($Body) + $title = if ($Body -and $Body.title) { [string]$Body.title } else { "Git-Repo-Ordner auswaehlen" } + try { + $path = Show-OpenFolderDialog -Title $title + } catch { + return @{ __status = 500; error = "Ordner-Dialog fehlgeschlagen: $($_.Exception.Message)" } + } + if (-not $path) { return @{ ok = $true; cancelled = $true } } + return @{ ok = $true; path = $path } +} + # App-Typ (@odata.type) -> unterstuetzter Content-Update-Pfad + erlaubte Endung. # Phase 1: nur win32LobApp/.intunewin aktiv; MSI/MSIX kommen in Phase 2/3. function Get-AppContentTypeMap { diff --git a/src/Models.ps1 b/src/Models.ps1 index 5689c5c..4bd8687 100644 --- a/src/Models.ps1 +++ b/src/Models.ps1 @@ -74,6 +74,13 @@ function Get-DefaultSettings { # Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis. groupNames = @() } + policyBackup = [pscustomobject]@{ + # Lokaler Git-Repo-Ordner fuer Policy-Snapshots. Leer = Funktion aus. + gitRepoPath = "" + # Nach dem Commit automatisch 'git push' ausfuehren (nutzt den + # vorhandenen Git-Credential-Helper; kein Token in der App). + push = $false + } userSearch = [pscustomobject]@{ # In welchen Feldern bei der Benutzersuche gesucht wird. # Erlaubt: displayName, userPrincipalName, mail, department. diff --git a/src/PolicyIO.ps1 b/src/PolicyIO.ps1 index 2022250..3bbe964 100644 --- a/src/PolicyIO.ps1 +++ b/src/PolicyIO.ps1 @@ -1,7 +1,7 @@ # 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). +# Unterstuetzte Typen: Compliance Policies, Configuration Profiles (Templates), +# Settings Catalog und Administrative Vorlagen (ADMX / groupPolicyConfigurations). +# 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, @@ -73,6 +73,20 @@ function Get-PolicyTypeConfig { Label = 'Settings Catalog' } } + 'administrativetemplate' { + return @{ + Key = 'administrativetemplate' + Collection = 'groupPolicyConfigurations' + NameField = 'displayName' + # Sonderfall: die konfigurierten Werte liegen nicht inline in der + # Policy, sondern in der definitionValues-Subcollection. Deshalb + # kein simples $expand -> eigene Behandlung in Get-GraphPolicyDetail. + ExportExpand = $null + ExportType = 'AdministrativeTemplate' + FilePrefix = 'AdminTemplate' + Label = 'Administrative Vorlage' + } + } default { return $null } } } @@ -80,7 +94,7 @@ function Get-PolicyTypeConfig { # ExportType (aus Datei/Envelope) -> Typ-Config. Reverse-Lookup fuer den Import. function Get-PolicyTypeConfigByExportType { param([string]$ExportType) - foreach ($key in @('compliance','configuration','settingscatalog')) { + foreach ($key in @('compliance','configuration','settingscatalog','administrativetemplate')) { $cfg = Get-PolicyTypeConfig $key if ($cfg.ExportType -eq $ExportType) { return $cfg } } @@ -112,6 +126,8 @@ function Get-PolicyPlatformLabel { $p = Get-PolicyProp $Raw 'platforms' return [string]$p } + # Administrative Vorlagen (groupPolicyConfigurations) sind reine Windows-Policies. + if (([string]$Type).ToLower() -eq 'administrativetemplate') { return 'Windows' } $t = [string](Get-PolicyProp $Raw '@odata.type') switch -Regex ($t) { 'windows' { return 'Windows' } @@ -158,6 +174,18 @@ function Get-GraphPolicyDetail { ) $cfg = Get-PolicyTypeConfig $Type if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" } + + # Administrative Vorlagen: Basis-Objekt holen und die konfigurierten Werte + # (definitionValues inkl. Definition + Presentation-Werten) separat expandieren. + if ($cfg.Key -eq 'administrativetemplate') { + $base = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" -Method GET + $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) + if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs } + else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force } + return $base + } + $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id" if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" } return Invoke-MgGraphRequestRetry -Uri $uri -Method GET @@ -183,6 +211,53 @@ function ConvertTo-ImportBody { return $body } +# Settings-Catalog-Payloads haben verschachtelte Properties, die laut Graph- +# Schema Arrays sein MUESSEN (settings, children, *SettingCollectionValue, values). +# Ein JSON-Roundtrip unter Windows PowerShell 5.1 entpackt Ein-Element-Arrays zu +# Einzelobjekten -> Graph antwortet mit 400 "... does not match schema". Diese +# Funktion baut den Baum rekursiv neu auf und packt betroffene Felder wieder in +# Arrays. Idempotent: bereits korrekte Arrays bleiben unveraendert. +function Repair-SettingsCatalogArrays { + param($Node) + $arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','values') + + # Array-Property normalisieren: $null -> @() (Graph-Schema: Collections sind + # Nullable=False, ein 'children': null wird abgelehnt), Einzelobjekt -> @(obj). + # Inline (nicht als Funktion), sonst entpackt der Return ein Ein-Element-Array. + if ($Node -is [System.Collections.IDictionary]) { + $out = [ordered]@{} + foreach ($k in @($Node.Keys)) { + $fixed = Repair-SettingsCatalogArrays $Node[$k] + if ($k -in $arrayKeys) { + if ($null -eq $fixed) { $fixed = @() } + elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) } + } + $out[$k] = $fixed + } + return $out + } + if ($Node -is [System.Management.Automation.PSCustomObject]) { + $out = [ordered]@{} + foreach ($p in $Node.PSObject.Properties) { + $fixed = Repair-SettingsCatalogArrays $p.Value + if ($p.Name -in $arrayKeys) { + if ($null -eq $fixed) { $fixed = @() } + elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) } + } + $out[$p.Name] = $fixed + } + return $out + } + if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) { + # Kein Komma-Operator: ein Ein-Element-Array wird beim Return zwar zum + # Skalar entpackt, aber jede Array-Property wird vom Parent ohnehin wieder + # in @(...) gewrappt. Ein fuehrendes ',' wuerde das Top-Level-settings- + # Array faelschlich in ein Extra-Array verschachteln. + return @($Node | ForEach-Object { Repair-SettingsCatalogArrays $_ }) + } + return $Node +} + function New-DefaultComplianceScheduledActions { # Compliance Policies verlangen beim Anlegen mindestens einen # scheduledActionsForRule-Block, sonst antwortet Graph mit 400. @@ -247,18 +322,106 @@ function Import-GraphSettingsCatalogPolicy { 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'] = @() } + # Zusaetzlich Array-Properties reparieren (PS-5.1-Roundtrip-Schaden), sonst + # 400 "Property children ... does not match schema". + if ($body.ContainsKey('settings') -and $null -ne $body['settings']) { + $body['settings'] = @(Repair-SettingsCatalogArrays $body['settings']) + # Beim GET liefert Graph die Setting-Wrapper ohne '@odata.type' und mit + # read-only 'id'. Der POST verlangt aber den Wrapper-Typ; die 'id' muss + # weg -> sonst 400 "Property settings ... does not match schema". + foreach ($s in $body['settings']) { + if ($s -is [System.Collections.IDictionary]) { + if ($s.Contains('id')) { [void]$s.Remove('id') } + if (-not $s.Contains('@odata.type')) { + $s['@odata.type'] = '#microsoft.graph.deviceManagementConfigurationSetting' + } + } + } + } else { + $body['settings'] = @() + } $json = $body | ConvertTo-Json -Depth 50 return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' } +function Import-GraphAdministrativeTemplate { + param([Parameter(Mandatory=$true)]$Policy) + + $displayName = [string](Get-PolicyProp $Policy 'displayName') + if (-not $displayName) { throw 'Administrative Vorlage benoetigt "displayName" fuer den Import.' } + + $defRoot = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions' + $cfgRoot = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations' + + # 1) Leere Konfigurations-Huelle anlegen (definitionValues folgen einzeln). + $shell = @{ + displayName = $displayName + description = [string](Get-PolicyProp $Policy 'description') + roleScopeTagIds = @('0') + } + $created = Invoke-MgGraphRequestRetry -Uri $cfgRoot -Method POST -Body ($shell | ConvertTo-Json -Depth 10) -ContentType 'application/json' + $newId = [string](Get-PolicyProp $created 'id') + if (-not $newId) { throw 'Anlegen der Administrative-Vorlage-Huelle lieferte keine Id.' } + + # 2) Jeden definitionValue einzeln anhaengen. Definition + Presentations werden + # per @odata.bind referenziert — die IDs eingebauter ADMX-Vorlagen sind + # tenantuebergreifend identisch, daher tenantunabhaengig einsetzbar. + $errors = @() + foreach ($dv in @(Get-PolicyProp $Policy 'definitionValues')) { + if (-not $dv) { continue } + $def = Get-PolicyProp $dv 'definition' + $defId = [string](Get-PolicyProp $def 'id') + if (-not $defId) { + $errors += 'definitionValue ohne Definition-Id uebersprungen' + continue + } + + $presVals = @() + foreach ($pv in @(Get-PolicyProp $dv 'presentationValues')) { + if (-not $pv) { continue } + $pres = Get-PolicyProp $pv 'presentation' + $presId = [string](Get-PolicyProp $pres 'id') + $entry = [ordered]@{ + '@odata.type' = [string](Get-PolicyProp $pv '@odata.type') + 'presentation@odata.bind' = "$defRoot('$defId')/presentations('$presId')" + } + # Je nach Presentation-Typ traegt der Wert in 'value' ODER 'values'. + foreach ($vk in @('value','values')) { + $vv = Get-PolicyProp $pv $vk + if ($null -ne $vv) { $entry[$vk] = $vv } + } + $presVals += $entry + } + + $body = [ordered]@{ + enabled = [bool](Get-PolicyProp $dv 'enabled') + 'definition@odata.bind' = "$defRoot('$defId')" + presentationValues = @($presVals) + } + try { + Invoke-MgGraphRequestRetry -Uri "$cfgRoot/$newId/definitionValues" -Method POST -Body ($body | ConvertTo-Json -Depth 50) -ContentType 'application/json' | Out-Null + } catch { + $m = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {} + $dn = [string](Get-PolicyProp $def 'displayName'); if (-not $dn) { $dn = $defId } + $errors += "${dn}: $m" + } + } + + if ($errors.Count -gt 0) { + throw ("Huelle angelegt (Id $newId), aber $($errors.Count) Einstellung(en) fehlgeschlagen: " + ($errors -join ' | ')) + } + return $created +} + # Dispatcht anhand des ExportType auf den passenden Import. 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 } + 'CompliancePolicy' { return Import-GraphCompliancePolicy -Policy $Policy } + 'ConfigurationProfile' { return Import-GraphConfigurationProfile -Policy $Policy } + 'SettingsCatalog' { return Import-GraphSettingsCatalogPolicy -Policy $Policy } + 'AdministrativeTemplate' { return Import-GraphAdministrativeTemplate -Policy $Policy } default { throw "Unbekannter exportType: $ExportType" } } } @@ -288,6 +451,11 @@ function Get-SettingsCatalogPoliciesEndpoint { return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'settingscatalog') } } +function Get-AdministrativeTemplatesEndpoint { + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'administrativetemplate') } +} + # Export: liefert die Export-Objekte im Response (Browser-Download) UND legt sie # zusaetzlich als JSON im Server-Archiv ab. Body: { type, ids }. function Export-PoliciesEndpoint { @@ -368,10 +536,14 @@ 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') + # @(Get-PolicyProp ...) auf ein fehlendes Feld liefert $null -> @($null) hat + # Count 1 (ein Null-Element), kein leeres Array. Ohne Filter wuerde die + # Archiv-Schleife einmal mit $fileName = $null laufen und auf das Verzeichnis + # selbst zugreifen (DirectoryNotFoundException). Daher Null/Leer rausfiltern. + $uploaded = @(Get-PolicyProp $Body 'policies') | Where-Object { $_ } + $fileNames = @(Get-PolicyProp $Body 'fileNames') | Where-Object { $_ } - if ($uploaded.Count -eq 0 -and $fileNames.Count -eq 0) { + if (@($uploaded).Count -eq 0 -and @($fileNames).Count -eq 0) { return @{ __status = 400; error = 'Keine Policies zum Importieren uebergeben' } } @@ -380,7 +552,10 @@ function Import-PoliciesEndpoint { # 1) Hochgeladene Envelopes foreach ($env in $uploaded) { if (-not $env) { continue } + # Envelope-Formate akzeptieren beide Typ-Felder: 'exportType' (Export- + # Download) und 'policyType' (Git-Snapshot). $exportType = [string](Get-PolicyProp $env 'exportType') + if (-not $exportType) { $exportType = [string](Get-PolicyProp $env 'policyType') } $policy = Get-PolicyProp $env 'policy' $policyName = Get-PolicyProp $env 'policyName' if (-not $policyName) { $policyName = Get-PolicyDisplayName $policy } @@ -401,9 +576,10 @@ function Import-PoliciesEndpoint { # 2) Dateien aus dem Server-Archiv $exportDir = Get-PolicyExportDir foreach ($fileName in $fileNames) { - $safe = ($fileName -replace '[\\/]', '') + if ([string]::IsNullOrWhiteSpace([string]$fileName)) { continue } + $safe = ([string]$fileName -replace '[\\/]', '') $filePath = Join-Path $exportDir $safe - if (-not (Test-Path $filePath)) { + if ([string]::IsNullOrWhiteSpace($safe) -or -not (Test-Path $filePath -PathType Leaf)) { $results += @{ fileName = $fileName; success = $false; error = 'Datei nicht gefunden' } continue } @@ -411,6 +587,7 @@ function Import-PoliciesEndpoint { try { $content = Get-Content $filePath -Raw | ConvertFrom-Json $exportType = [string](Get-PolicyProp $content 'exportType') + if (-not $exportType) { $exportType = [string](Get-PolicyProp $content 'policyType') } $policy = Get-PolicyProp $content 'policy' $policyName = Get-PolicyDisplayName $policy $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy @@ -425,3 +602,179 @@ function Import-PoliciesEndpoint { $ok = @($results | Where-Object { $_.success }).Count return @{ ok = $true; importedCount = $ok; results = @($results) } } + +# Weist importierten Policies Gruppen zu (Include + Exclude). Body: +# items = [ { exportType, id, policyName, include:[groupId], exclude:[groupId] } ] +# Nutzt die typ-spezifische /assign-Action. Zuweisungen sind pro Policy. +function Invoke-PolicyAssignEndpoint { + param($Body) + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + + $items = @(Get-PolicyProp $Body 'items') | Where-Object { $_ } + if (@($items).Count -eq 0) { return @{ __status = 400; error = 'Keine Zuweisungen uebergeben' } } + + $results = @() + foreach ($it in $items) { + $exportType = [string](Get-PolicyProp $it 'exportType') + $id = [string](Get-PolicyProp $it 'id') + $name = [string](Get-PolicyProp $it 'policyName') + $include = @(Get-PolicyProp $it 'include') | Where-Object { $_ } + $exclude = @(Get-PolicyProp $it 'exclude') | Where-Object { $_ } + $cfg = Get-PolicyTypeConfigByExportType $exportType + + if (-not $cfg) { $results += @{ id = $id; policyName = $name; success = $false; error = "Unbekannter exportType: $exportType" }; continue } + if (-not $id) { $results += @{ policyName = $name; success = $false; error = 'Policy-Id fehlt' }; continue } + if (@($include).Count -eq 0 -and @($exclude).Count -eq 0) { + $results += @{ id = $id; policyName = $name; success = $true; skipped = $true } + continue + } + + $assignments = @() + foreach ($g in $include) { + $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } } + } + foreach ($g in $exclude) { + $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = [string]$g } } + } + $json = @{ assignments = @($assignments) } | ConvertTo-Json -Depth 10 + $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assign" + try { + Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json' | Out-Null + $results += @{ id = $id; policyName = $name; success = $true; includeCount = @($include).Count; excludeCount = @($exclude).Count } + } catch { + $m = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {} + $results += @{ id = $id; policyName = $name; success = $false; error = $m } + } + } + + $ok = @($results | Where-Object { $_.success -and -not $_.skipped }).Count + return @{ ok = $true; assignedCount = $ok; results = @($results) } +} + +# ============================================================ +# Policy-Snapshot nach Git (voller Export, stabile Dateinamen) +# ============================================================ + +# Rekursiv nach Schluesseln sortieren -> deterministische JSON-Ausgabe, damit +# unveraenderte Policies keine Diff-Noise durch wechselnde Key-Reihenfolge +# erzeugen (Invoke-MgGraphRequest liefert ungeordnete Hashtables). +function ConvertTo-StableObject { + param($InputObject) + if ($InputObject -is [System.Collections.IDictionary]) { + $ordered = [ordered]@{} + foreach ($k in ($InputObject.Keys | Sort-Object)) { + $ordered[$k] = ConvertTo-StableObject $InputObject[$k] + } + return $ordered + } + if (($InputObject -is [System.Collections.IEnumerable]) -and -not ($InputObject -is [string])) { + # Array-Reihenfolge bleibt erhalten (ist bei Policies bedeutungstragend). + return @($InputObject | ForEach-Object { ConvertTo-StableObject $_ }) + } + return $InputObject +} + +function Invoke-Git { + param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs) + $out = & git -C $RepoPath @GitArgs 2>&1 + return @{ exit = $LASTEXITCODE; out = (@($out) -join "`n").Trim() } +} + +function Get-PolicySnapshotFolderName { + # Tenant-Unterordner im Repo: Label (Multi-Tenant) sonst TenantId sonst 'default'. + $active = Get-ActiveConnection -Settings $script:Settings + $name = if ($active.label) { $active.label } elseif ($script:State.TenantId) { [string]$script:State.TenantId } else { 'default' } + $safe = ($name -replace '[^\w\-\.]', '_') + if ([string]::IsNullOrWhiteSpace($safe)) { $safe = 'default' } + return $safe +} + +function Invoke-PolicyGitSnapshotEndpoint { + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + + $cfg = $script:Settings.policyBackup + $repo = if ($cfg -and $cfg.gitRepoPath) { [string]$cfg.gitRepoPath } else { '' } + $doPush = if ($cfg -and $cfg.push) { [bool]$cfg.push } else { $false } + if ([string]::IsNullOrWhiteSpace($repo)) { + return @{ __status = 400; error = 'Kein Git-Repo-Pfad konfiguriert (Einstellungen -> Policy-Backup).' } + } + + # git verfuegbar? + try { $null = & git --version 2>&1; if ($LASTEXITCODE -ne 0) { throw 'x' } } + catch { return @{ __status = 500; error = 'git ist nicht installiert oder nicht im PATH.' } } + + # Repo-Ordner + .git sicherstellen + if (-not (Test-Path $repo)) { New-Item -ItemType Directory -Path $repo -Force | Out-Null } + if (-not (Test-Path (Join-Path $repo '.git'))) { + $r = Invoke-Git -RepoPath $repo -GitArgs @('init') + if ($r.exit -ne 0) { return @{ __status = 500; error = "git init fehlgeschlagen: $($r.out)" } } + } + # Commit-Identitaet sicherstellen (frisches Repo hat evtl. keine). + if ([string]::IsNullOrWhiteSpace((Invoke-Git -RepoPath $repo -GitArgs @('config','user.email')).out)) { + Invoke-Git -RepoPath $repo -GitArgs @('config','user.email','intune-manager@localhost') | Out-Null + Invoke-Git -RepoPath $repo -GitArgs @('config','user.name','Intune Manager') | Out-Null + } + + $tenantFolder = Get-PolicySnapshotFolderName + $tenantDir = Join-Path $repo $tenantFolder + # Tenant-Ordner komplett neu aufbauen -> entfernte Policies verschwinden (Diff). + if (Test-Path $tenantDir) { Remove-Item $tenantDir -Recurse -Force } + New-Item -ItemType Directory -Path $tenantDir -Force | Out-Null + + $types = @('settingscatalog','compliance','configuration','administrativetemplate') + $total = 0 + $perType = [ordered]@{} + foreach ($type in $types) { + $tcfg = Get-PolicyTypeConfig $type + $list = @(Get-GraphPolicyList -Type $type) + $perType[$tcfg.ExportType] = $list.Count + if ($list.Count -eq 0) { continue } + $typeDir = Join-Path $tenantDir $type + New-Item -ItemType Directory -Path $typeDir -Force | Out-Null + foreach ($item in $list) { + $id = [string]$item.id + $detail = $null + try { $detail = Get-GraphPolicyDetail -Type $type -Id $id } catch { continue } + $name = [string]$item.name + $safe = ($name -replace '[^\w\-\.]', '_'); if (-not $safe) { $safe = $id } + $short = if ($id.Length -ge 8) { $id.Substring(0,8) } else { $id } + $fname = "$($safe)__$($short).json" + $envelope = [ordered]@{ + policyType = $tcfg.ExportType + policyName = $name + policy = $detail + } + # stabile (sortierte) Ausgabe, ohne Zeitstempel -> saubere Diffs + (ConvertTo-StableObject $envelope) | ConvertTo-Json -Depth 50 | Set-Content -Path (Join-Path $typeDir $fname) -Encoding UTF8 + $total++ + } + } + + $add = Invoke-Git -RepoPath $repo -GitArgs @('add','-A') + if ($add.exit -ne 0) { return @{ __status = 500; error = "git add fehlgeschlagen: $($add.out)" } } + + if ([string]::IsNullOrWhiteSpace((Invoke-Git -RepoPath $repo -GitArgs @('status','--porcelain')).out)) { + return @{ ok = $true; changed = $false; committed = $false; total = $total; perType = $perType; tenant = $tenantFolder; message = 'Keine Aenderungen seit dem letzten Snapshot.' } + } + + $msg = "Policy-Snapshot $tenantFolder $(Get-Date -Format 'yyyy-MM-dd HH:mm')" + $commit = Invoke-Git -RepoPath $repo -GitArgs @('commit','-m',$msg) + if ($commit.exit -ne 0) { return @{ __status = 500; error = "git commit fehlgeschlagen: $($commit.out)" } } + $hash = (Invoke-Git -RepoPath $repo -GitArgs @('rev-parse','--short','HEAD')).out + + $pushed = $false; $pushError = $null + if ($doPush) { + $push = Invoke-Git -RepoPath $repo -GitArgs @('push') + if ($push.exit -eq 0) { $pushed = $true } else { $pushError = $push.out } + } + + $summary = "Snapshot committet: $total Policies ($hash)" + if ($doPush) { $summary += if ($pushed) { ', gepusht' } else { ', Push fehlgeschlagen' } } + return @{ + ok = $true; changed = $true; committed = $true + total = $total; perType = $perType; tenant = $tenantFolder + commit = $hash; pushed = $pushed; pushError = $pushError + message = $summary + } +} diff --git a/www/app.js b/www/app.js index 2fc3e6d..86feacb 100644 --- a/www/app.js +++ b/www/app.js @@ -194,9 +194,11 @@ document.getElementById('tenantSwitch')?.addEventListener('change', async e => { State.readOnly = !!s.readOnly; applyReadOnlyMode(); renderConnection(); - // Frontend-Caches anderer Tabs verwerfen (anderer Tenant = andere Daten). - if (typeof ReportState !== 'undefined') ReportState.items = []; - if (typeof PolState !== 'undefined') { PolState.items = []; PolState.selected?.clear?.(); } + // Anderer Tenant = andere Daten: ALLE datentragenden Frontend-Caches + // verwerfen, sonst ueberspringen die Load-Guards (appsLoaded/loadedModes) + // das Neuladen und die UI zeigt weiter den vorherigen Tenant. + if (typeof stopDevicePolling === 'function') stopDevicePolling(); + resetClientState(); switchMainView('apps'); if (s.connected) { toast('Verbunden mit ' + (s.tenantLabel || s.account || 'Mandant'), 'ok', 'Mandant gewechselt'); @@ -345,17 +347,8 @@ document.getElementById('btnDisconnect').addEventListener('click', async () => { connectInFlight = false; try { await api('/api/disconnect', { method: 'POST' }); - Object.assign(State, { - connected: false, account: null, - targets: [], apps: [], session: [], - appsLoaded: false, - }); - State.selectedTargetIds.clear(); - State.membershipCache.clear(); - State.loadedModes.clear(); - State.appDetails.clear(); - State.expandedApps.clear(); - State.modeCache.clear(); + Object.assign(State, { connected: false, account: null }); + resetClientState(); renderConnection(); renderTargets(); renderPinnedTargets(); @@ -368,6 +361,25 @@ document.getElementById('btnDisconnect').addEventListener('click', async () => { } }); +// Alle datentragenden Frontend-Caches verwerfen (Apps, Targets, Gruppen- +// Mitglieder, Report, Policies, Devices). Beruehrt NICHT connected/account — +// das setzt der jeweilige Aufrufer (Disconnect vs. Tenant-Wechsel) selbst. +// Wird beim Tenant-Wechsel benoetigt, sonst zeigen die Load-Guards +// (appsLoaded / loadedModes) noch die Daten des vorherigen Tenants. +function resetClientState() { + Object.assign(State, { targets: [], apps: [], session: [], appsLoaded: false }); + State.selectedTargetIds.clear(); + State.membershipCache.clear(); + State.loadedModes.clear(); + State.appDetails.clear(); + State.expandedApps.clear(); + State.modeCache.clear(); + State.expandedGroups = new Map(); + if (typeof ReportState !== 'undefined') ReportState.items = []; + if (typeof PolState !== 'undefined') { PolState.items = []; PolState.selected?.clear?.(); } + if (typeof DevState !== 'undefined') { DevState.items = []; DevState.filtered = []; DevState.selected = null; } +} + async function autoLoadAfterConnect() { // Load default mode targets + apps in parallel const tasks = []; @@ -4119,6 +4131,11 @@ function fillSettingsForm(s) { document.getElementById('setAvailSuffix').value = av.suffix || ''; updateAvailPreview(); + // Policy-Backup (Git) + const pb = s.policyBackup || {}; + document.getElementById('setPolicyGitPath').value = pb.gitRepoPath || ''; + document.getElementById('setPolicyGitPush').checked = !!pb.push; + // Branding-Logo Preview fillLogoPreview(s.branding); @@ -4219,6 +4236,10 @@ function readSettingsForm() { detailPanel: document.getElementById('setColDetailPanelHex').value.trim() || ThemeDefaults.detailPanel, }, }, + policyBackup: { + gitRepoPath: document.getElementById('setPolicyGitPath').value.trim(), + push: document.getElementById('setPolicyGitPush').checked, + }, }; } @@ -5751,6 +5772,7 @@ async function loadPolicies() { ['/api/policies/settingscatalog', 'Settings Catalog'], ['/api/policies/compliance', 'Compliance'], ['/api/policies/configuration', 'Konfigurationsprofile'], + ['/api/policies/administrativetemplate', 'Administrative Vorlagen'], ]; const settled = await Promise.allSettled(endpoints.map(([url]) => api(url))); let items = []; @@ -5891,7 +5913,8 @@ async function polExportSelected() { // 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]; + // Beide Typ-Felder akzeptieren: exportType (Export-Download) + policyType (Git-Snapshot). + if (parsed && (parsed.exportType || parsed.policyType) && parsed.policy) return [parsed]; if (parsed && parsed.policy) return [parsed]; // nachsichtig toast(`${fileName}: unbekanntes Policy-Format.`, 'err'); return []; @@ -5922,6 +5945,9 @@ async function polHandleImportFiles(fileList) { toast(`${ok.length} Policy(s) importiert.`, 'ok', 'Import'); } await loadPolicies(); + // Nach erfolgreichem Import optional Gruppen zuweisen (pro Policy). + const assignable = ok.filter(r => r.newId); + if (assignable.length && !State.readOnly) openPolAssignModal(assignable); } catch (e) { toast('Import fehlgeschlagen: ' + e.message, 'err'); } finally { @@ -5929,8 +5955,204 @@ async function polHandleImportFiles(fileList) { } } +// ============================================================= +// Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen +// ============================================================= +const PolAssignState = { policies: [] }; +const POL_TYPE_LABELS = { + SettingsCatalog: 'Settings Catalog', + CompliancePolicy: 'Compliance', + ConfigurationProfile: 'Konfigurationsprofil', + AdministrativeTemplate: 'Administrative Vorlage', +}; + +function openPolAssignModal(assignable) { + PolAssignState.policies = assignable.map(r => ({ + id: r.newId, + name: r.policyName || '(ohne Name)', + exportType: r.exportType || '', + include: [], + exclude: [], + })); + renderPolAssignList(); + openModal('modalPolAssign'); +} + +function renderPolAssignList() { + const list = document.getElementById('polAssignList'); + if (!list) return; + list.innerHTML = PolAssignState.policies.map((p, i) => ` +
+
+ ${escapeHtml(p.name)} + ${escapeHtml(POL_TYPE_LABELS[p.exportType] || p.exportType || 'Policy')} +
+
+ ${['include', 'exclude'].map(kind => ` +
+ +
+
+ + +
+
+ `).join('')} +
+
+ `).join(''); + PolAssignState.policies.forEach((_, i) => { + renderPolAssignChips(i, 'include'); + renderPolAssignChips(i, 'exclude'); + }); + updatePolAssignInfo(); +} + +function renderPolAssignChips(idx, kind) { + const box = document.querySelector(`.pol-assign-chips[data-idx="${idx}"][data-kind="${kind}"]`); + if (!box) return; + const arr = PolAssignState.policies[idx][kind]; + box.innerHTML = arr.map((g, j) => ` + + ${escapeHtml(g.name)} + + + `).join(''); + updatePolAssignInfo(); +} + +function updatePolAssignInfo() { + const info = document.getElementById('polAssignInfo'); + if (!info) return; + const n = PolAssignState.policies.filter(p => p.include.length || p.exclude.length).length; + info.textContent = n ? `${n} Policy(s) mit Zuweisung` : 'Keine Zuweisung gewählt'; +} + +let _polAssignSearchTimer = null; +function polAssignHideDropdowns() { + document.querySelectorAll('.pol-assign-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; }); +} + +// Suche (debounced) — Event-Delegation auf der Liste. +document.getElementById('polAssignList')?.addEventListener('input', e => { + const input = e.target.closest('.pol-assign-search'); + if (!input) return; + const idx = input.dataset.idx, kind = input.dataset.kind; + const dd = document.querySelector(`.pol-assign-dropdown[data-idx="${idx}"][data-kind="${kind}"]`); + const q = input.value.trim(); + clearTimeout(_polAssignSearchTimer); + if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; } + if (dd) { dd.classList.remove('hidden'); dd.innerHTML = '
Suche…
'; } + _polAssignSearchTimer = setTimeout(async () => { + try { + const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); + const items = (data.items || []).slice(0, 25); + if (!dd) return; + if (!items.length) { dd.innerHTML = '
Keine Gruppen gefunden.
'; return; } + dd.innerHTML = items.map(g => ` +
+
${escapeHtml(g.DisplayName)}
+ ${g.Description ? `
${escapeHtml(g.Description)}
` : ''} +
`).join(''); + } catch (err) { + if (dd) dd.innerHTML = `
Suche fehlgeschlagen: ${escapeHtml(err.message)}
`; + } + }, 300); +}); + +// Klick auf ein Suchergebnis -> als Chip hinzufügen. Klick auf Chip-× -> entfernen. +document.getElementById('polAssignList')?.addEventListener('click', e => { + const opt = e.target.closest('.pol-assign-dropdown .opt'); + if (opt) { + const dd = opt.closest('.pol-assign-dropdown'); + const idx = +dd.dataset.idx, kind = dd.dataset.kind; + const id = opt.dataset.id, name = opt.dataset.name; + const p = PolAssignState.policies[idx]; + const other = kind === 'include' ? 'exclude' : 'include'; + // Nicht doppelt, und nicht gleichzeitig include+exclude derselben Gruppe. + if (!p[kind].some(g => g.id === id) && !p[other].some(g => g.id === id)) { + p[kind].push({ id, name }); + renderPolAssignChips(idx, kind); + } + const input = document.querySelector(`.pol-assign-search[data-idx="${idx}"][data-kind="${kind}"]`); + if (input) input.value = ''; + dd.classList.add('hidden'); dd.innerHTML = ''; + return; + } + const rm = e.target.closest('.pol-assign-chip button'); + if (rm) { + const idx = +rm.dataset.idx, kind = rm.dataset.kind, j = +rm.dataset.j; + PolAssignState.policies[idx][kind].splice(j, 1); + renderPolAssignChips(idx, kind); + } +}); +document.addEventListener('click', e => { + if (!e.target.closest('.pol-assign-search-wrap')) polAssignHideDropdowns(); +}); + +document.getElementById('polAssignApply')?.addEventListener('click', async () => { + const items = PolAssignState.policies + .filter(p => p.include.length || p.exclude.length) + .map(p => ({ + exportType: p.exportType, + id: p.id, + policyName: p.name, + include: p.include.map(g => g.id), + exclude: p.exclude.map(g => g.id), + })); + if (!items.length) { closeModal('modalPolAssign'); return; } + setLoading('Weise Gruppen zu…'); + try { + const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 120000 }); + const results = res.results || []; + const fail = results.filter(r => !r.success); + if (fail.length) { + const first = fail[0]; + toast(`${res.assignedCount || 0} zugewiesen, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Zuweisung'); + } else { + toast(`${res.assignedCount || 0} Policy(s) zugewiesen.`, 'ok', 'Zuweisung'); + } + closeModal('modalPolAssign'); + } catch (e) { + toast('Zuweisung fehlgeschlagen: ' + e.message, 'err'); + } finally { + clearLoading(); + } +}); + +// Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben + committen. +async function polGitSnapshot() { + setLoading('Erstelle Policy-Snapshot & committe…'); + try { + const res = await api('/api/policies/git-snapshot', { method: 'POST', timeoutMs: 300000 }); + if (res.changed === false) { + toast(res.message || 'Keine Änderungen seit dem letzten Snapshot.', 'info', 'Git-Snapshot'); + } else { + let msg = res.message || `Snapshot committet (${res.total} Policies).`; + if (res.pushError) msg += ` — Push-Fehler: ${res.pushError}`; + toast(msg, res.pushError ? 'warn' : 'ok', 'Git-Snapshot'); + } + } catch (e) { + toast('Snapshot fehlgeschlagen: ' + e.message, 'err'); + } finally { + clearLoading(); + } +} + +// Ordner-Auswahl für den Git-Repo-Pfad (Einstellungen). +async function pickPolicyGitFolder() { + try { + const res = await api('/api/pickfolder', { method: 'POST', body: { title: 'Git-Repo-Ordner für Policy-Backup wählen' } }); + if (res && res.path) document.getElementById('setPolicyGitPath').value = res.path; + } catch (e) { + toast('Ordner-Auswahl fehlgeschlagen: ' + e.message, 'err'); + } +} +document.getElementById('btnPickPolicyGit')?.addEventListener('click', pickPolicyGitFolder); + // --- Policies: Event-Wiring --- document.getElementById('btnPolRefresh')?.addEventListener('click', loadPolicies); +document.getElementById('btnPolGitSnapshot')?.addEventListener('click', polGitSnapshot); document.getElementById('btnPolExport')?.addEventListener('click', polExportSelected); document.getElementById('btnPolImport')?.addEventListener('click', () => document.getElementById('polImportFile').click()); document.getElementById('polImportFile')?.addEventListener('change', e => { diff --git a/www/index.html b/www/index.html index 7806691..60db440 100644 --- a/www/index.html +++ b/www/index.html @@ -572,6 +572,7 @@ + + + + + + + + +