# Datenklassen / Hilfen fuer Frontend-JSON function ConvertTo-Json2 { param([Parameter(ValueFromPipeline=$true)]$InputObject, [int]$Depth = 12) process { return $InputObject | ConvertTo-Json -Depth $Depth -Compress } } # Normalisiert ein potentielles Datum (DateTime, DateTimeOffset, String, # /Date(ms)/, $null) auf ein ISO-8601-String — damit JS' new Date() es # zuverlaessig parsen kann. function ConvertTo-IsoDate { param($Value) if ($null -eq $Value) { return $null } if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('o') } if ($Value -is [DateTimeOffset]) { return $Value.UtcDateTime.ToString('o') } $s = [string]$Value if ([string]::IsNullOrWhiteSpace($s)) { return $null } # Microsoft-Legacy "/Date(1234567890123)/" if ($s -match '^\/Date\((-?\d+)') { try { return ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$matches[1])).UtcDateTime.ToString('o') } catch {} } # Schon ein parsbares Datum? try { return ([DateTime]$s).ToUniversalTime().ToString('o') } catch {} return $s } # ============================================================ # Settings: editierbare Konfiguration, persistiert als JSON # unter %APPDATA%\IntuneAppManager-Web\settings.json # ============================================================ function Get-SettingsPath { $dir = Join-Path $env:APPDATA "IntuneAppManager-Web" if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } return Join-Path $dir "settings.json" } function Get-DefaultSettings { # Tenant-spezifische Felder (tenantId, clientId, departments.prefix, # rpa.groupNames) sind absichtlich leer — eine frische Installation # zwingt den Admin durchs Setup. Generische Werte (Scopes, Naming-Schemas, # Theme) sind branchenuebliche Startpunkte und bleiben besetzt. return [pscustomobject]@{ connection = [pscustomobject]@{ # Verbindungsmodus: # $false = Single-Tenant (klassisch: die flachen Felder unten) # $true = Multi-Tenant (Liste 'tenants' + 'activeTenantId') multiTenant = $false tenantId = "" clientId = "" clientIdRo = "" # DeviceManagementApps.ReadWrite.All ist Pflicht: assign (native All # Users/Devices), PATCH (Rename) und DELETE auf mobileApps brauchen # ReadWrite — Read.All allein liefert dort 403. Verifiziert via # msgraph-Skill gegen den offiziellen Graph-Permission-Index. scopes = @("Group.ReadWrite.All", "GroupMember.ReadWrite.All", "User.Read.All", "DeviceManagementApps.ReadWrite.All") scopesRo = @("Group.Read.All", "GroupMember.Read.All", "User.Read.All", "DeviceManagementApps.Read.All") # Multi-Tenant-Profile: je Tenant eigene App-Registrierung(en). # [{ id, label, tenantId, clientId, clientIdRo }]. Scopes gelten global. tenants = @() activeTenantId = "" } departments = [pscustomobject]@{ # Ein oder mehrere Praefixe fuer den Abteilungs-Modus (linke Spalte). # Mehrfach moeglich (z.B. "abt-hm" + "abt-extern"). Pflicht: mindestens # ein Eintrag im Setup. Der alte Single-String 'prefix' bleibt fuer # Rueckwaerts-Kompatibilitaet — Get-DepartmentPrefixes liest beides. prefixes = @() prefix = "" } rpa = [pscustomobject]@{ # 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. fields = @("displayName", "userPrincipalName", "mail") } requiredGroupNaming = [pscustomobject]@{ # Wird beim "+ Required-Gruppe"-Workflow verwendet: # {prefix}{slug-vom-app-name}{suffix} prefix = "intune-win-app-" suffix = "-required" } availableGroupNaming = [pscustomobject]@{ # Analog zu requiredGroupNaming — fuer "+ Available-Gruppe"-Workflow. prefix = "intune-win-app-" suffix = "-available" } branding = [pscustomobject]@{ # Logo-Dateiname relativ zum Projekt-Root (dort liegen auch # intune.png/pmpc.png/application.png). $null = Letter "I" Fallback. logoFile = "application.png" } # Vendor-Registry: jede App wird gegen diese Liste in Reihenfolge # gepruef; erster Match gewinnt. Source-String im App-Objekt = # vendor.displayName (sonst "Intune"). Tenants koennen Detection- # Regeln, Portal-URLs und Blocking pro Vendor in settings.json # ueberschreiben. vendors = @( [pscustomobject]@{ id = "patchmypc" displayName = "PatchMyPC" portalUrl = "https://portal.patchmypc.com/" logoFile = "pmpc.png" detection = [pscustomobject]@{ field = "commandLine" # commandLine | developer | publisher | displayName | notes | owner match = "contains" # contains | equals | regex | startsWith pattern = "PatchMyPC" } block = [pscustomobject]@{ delete = $true; rename = $true } }, [pscustomobject]@{ id = "robopack" displayName = "Robopack" portalUrl = "https://app.robopack.com/" logoFile = "robopack.png" detection = [pscustomobject]@{ field = "developer" match = "equals" pattern = "Robopack" } block = [pscustomobject]@{ delete = $true; rename = $true } } ) theme = [pscustomobject]@{ # Hauptfarben fuer Highlights. Soft/Strong/Border/Bg werden im # Frontend per rgba() aus dem Hex abgeleitet. colors = [pscustomobject]@{ brand = "#27a078" # Primaere Firmenfarbe: Logo-Hintergrund, Stepper-Indikator accent = "#3b82f6" # Available-Pillen, Links, Akzent-Hover required = "#cb2a7a" # Pink (Required-Badges) success = "#10b981" # Bereits zugewiesen warning = "#f59e0b" # Teilweise / Skip error = "#ef4444" # Fehler / Destructive rowSelected = "#71e5c4" # Hintergrund der aufgeklappten/markierten App-Zeile detailPanel = "#f7f7f7" # Hintergrund des Details-Bereichs unter der App } } } } # Tiefer Merge: gespeicherte Werte ueberschreiben Defaults, neue Default- # Keys werden trotzdem ergaenzt — robust gegen Settings-Schema-Erweiterungen. function Merge-Settings { param($Base, $Override) if ($null -eq $Override) { return $Base } $result = [ordered]@{} foreach ($p in $Base.PSObject.Properties) { $name = $p.Name $bv = $p.Value $ov = $null $hasOverride = $false if ($Override.PSObject.Properties[$name]) { $ov = $Override.PSObject.Properties[$name].Value $hasOverride = $true } if (-not $hasOverride) { $result[$name] = $bv } elseif ($bv -is [pscustomobject] -and $ov -is [pscustomobject]) { $result[$name] = Merge-Settings -Base $bv -Override $ov } else { $result[$name] = $ov } } # Zusaetzliche Properties aus Override, die nicht im Base sind, ignorieren — # sie wuerden vom Backend ohnehin nicht gelesen. return [pscustomobject]$result } function Get-DepartmentPrefixes { # Zentrale Quelle fuer die Abteilungs-Praefixe. Multi-Tenant: hat das aktive # Profil eigene 'prefixes', gelten diese (Override); sonst die globalen # departments.prefixes / alter Single-String 'prefix' (Rueckwaerts-Kompat). # Liefert immer ein String-Array (getrimmt, dedupliziert, ohne leere). param($Settings) $out = [System.Collections.Generic.List[string]]::new() if ($null -eq $Settings) { return ,$out.ToArray() } $candidates = @() # 1) Tenant-Override (aktives Profil) $prof = Get-ActiveTenantProfile -Settings $Settings if ($prof -and $prof.PSObject.Properties['prefixes']) { $profPfx = @($prof.prefixes | Where-Object { $_ -and ([string]$_).Trim() }) if ($profPfx.Count -gt 0) { $candidates = $profPfx } } # 2) Globale Vorgabe (Fallback, wenn kein Profil-Override) if ($candidates.Count -eq 0 -and $null -ne $Settings.departments) { $d = $Settings.departments if ($d.PSObject.Properties['prefixes']) { $candidates += @($d.prefixes) } if ($d.PSObject.Properties['prefix'] -and $d.prefix) { $candidates += @([string]$d.prefix) } } $seen = @{} foreach ($p in $candidates) { if ($null -eq $p) { continue } $t = ([string]$p).Trim() if (-not $t) { continue } $k = $t.ToLowerInvariant() if ($seen.ContainsKey($k)) { continue } $seen[$k] = $true $out.Add($t) } return $out.ToArray() } function Get-RpaGroupNames { # RPA-Gruppennamen fuer den aktuell aktiven Kontext. Multi-Tenant: aktives # Profil kann eigene 'rpaGroups' definieren (Override); sonst global. param($Settings) if ($null -eq $Settings) { return ,@() } $prof = Get-ActiveTenantProfile -Settings $Settings if ($prof -and $prof.PSObject.Properties['rpaGroups']) { $names = @($prof.rpaGroups | Where-Object { $_ -and ([string]$_).Trim() }) if ($names.Count -gt 0) { return ,@($names | ForEach-Object { [string]$_ }) } } if ($Settings.rpa -and $Settings.rpa.PSObject.Properties['groupNames']) { return ,@($Settings.rpa.groupNames | Where-Object { $_ -and ([string]$_).Trim() } | ForEach-Object { [string]$_ }) } return ,@() } function Get-GroupNaming { # Naming-Schema (prefix/suffix) fuer required/available. Multi-Tenant: das # aktive Profil kann prefix und/oder suffix ueberschreiben (pro Feld: nicht- # leerer Profilwert gewinnt, sonst globale Vorgabe, sonst Default). param($Settings, [string]$Intent) $isAvail = ($Intent -eq 'available') $defPrefix = 'intune-win-app-' $defSuffix = if ($isAvail) { '-available' } else { '-required' } $globalObj = if ($Settings) { if ($isAvail) { $Settings.availableGroupNaming } else { $Settings.requiredGroupNaming } } else { $null } $prefix = if ($globalObj -and $globalObj.prefix) { [string]$globalObj.prefix } else { $defPrefix } $suffix = if ($globalObj -and $globalObj.suffix) { [string]$globalObj.suffix } else { $defSuffix } $prof = Get-ActiveTenantProfile -Settings $Settings if ($prof) { $key = if ($isAvail) { 'availableGroupNaming' } else { 'requiredGroupNaming' } $pObj = if ($prof.PSObject.Properties[$key]) { $prof.$key } else { $null } if ($pObj) { if ($pObj.PSObject.Properties['prefix'] -and ([string]$pObj.prefix).Trim()) { $prefix = [string]$pObj.prefix } if ($pObj.PSObject.Properties['suffix'] -and ([string]$pObj.suffix).Trim()) { $suffix = [string]$pObj.suffix } } } return @{ prefix = $prefix; suffix = $suffix } } function Get-ActiveTenantProfile { # Liefert das aktive Tenant-Profil-Objekt (Multi-Tenant) oder $null im # Single-Tenant-Modus / wenn keine Profile existieren. param($Settings) if ($null -eq $Settings) { return $null } $c = $Settings.connection if ($null -eq $c) { return $null } if (-not ($c.PSObject.Properties['multiTenant'] -and [bool]$c.multiTenant)) { return $null } $tenants = @() if ($c.PSObject.Properties['tenants']) { $tenants = @($c.tenants | Where-Object { $_ }) } if ($tenants.Count -eq 0) { return $null } $activeId = if ($c.PSObject.Properties['activeTenantId']) { [string]$c.activeTenantId } else { "" } $p = $tenants | Where-Object { [string]$_.id -eq $activeId } | Select-Object -First 1 if (-not $p) { $p = $tenants[0] } return $p } function Get-ActiveConnection { # Liefert die aktuell zu verwendenden Verbindungswerte als PSCustomObject # { tenantId; clientId; clientIdRo; label }. Im Multi-Tenant-Modus das # aktive Profil (activeTenantId, sonst erstes Profil); sonst die flachen # connection-Felder (Rueckwaerts-Kompatibilitaet / Single-Tenant). param($Settings) $c = $Settings.connection $empty = [pscustomobject]@{ tenantId = ""; clientId = ""; clientIdRo = ""; label = "" } if ($null -eq $c) { return $empty } $isMulti = $false if ($c.PSObject.Properties['multiTenant']) { $isMulti = [bool]$c.multiTenant } if ($isMulti) { $tenants = @() if ($c.PSObject.Properties['tenants']) { $tenants = @($c.tenants | Where-Object { $_ }) } if ($tenants.Count -eq 0) { return $empty } $activeId = if ($c.PSObject.Properties['activeTenantId']) { [string]$c.activeTenantId } else { "" } $profile = $tenants | Where-Object { [string]$_.id -eq $activeId } | Select-Object -First 1 if (-not $profile) { $profile = $tenants[0] } return [pscustomobject]@{ tenantId = [string]$profile.tenantId clientId = [string]$profile.clientId clientIdRo = [string]$profile.clientIdRo label = [string]$profile.label } } return [pscustomobject]@{ tenantId = [string]$c.tenantId clientId = [string]$c.clientId clientIdRo = [string]$c.clientIdRo label = "" } } function Read-Settings { $path = Get-SettingsPath $defaults = Get-DefaultSettings if (-not (Test-Path $path)) { return $defaults } try { $raw = Get-Content -Path $path -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace($raw)) { return $defaults } $saved = $raw | ConvertFrom-Json return Merge-Settings -Base $defaults -Override $saved } catch { Write-Host "[SETTINGS] Lesen fehlgeschlagen, verwende Defaults: $($_.Exception.Message)" -ForegroundColor Yellow return $defaults } } function Write-Settings { param([Parameter(Mandatory=$true)]$Settings) $path = Get-SettingsPath $json = $Settings | ConvertTo-Json -Depth 10 [IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8) Write-Host "[SETTINGS] Gespeichert: $path" -ForegroundColor DarkGray } function Get-SettingsValidationErrors { # Rudimentaere Validierung. Schwere Fehler -> Speichern ablehnen. param($S) $errs = @() $isMulti = $false if ($S.connection.PSObject.Properties['multiTenant']) { $isMulti = [bool]$S.connection.multiTenant } if ($isMulti) { # Multi-Tenant: jedes Profil validieren; mindestens eines erforderlich. $tenants = @() if ($S.connection.PSObject.Properties['tenants']) { $tenants = @($S.connection.tenants | Where-Object { $_ }) } if ($tenants.Count -eq 0) { $errs += "Multi-Tenant aktiv, aber kein Tenant-Profil angelegt." } else { $seenIds = @{} foreach ($t in $tenants) { $lbl = if ($t.label) { [string]$t.label } else { [string]$t.tenantId } if (-not $t.id -or [string]::IsNullOrWhiteSpace($t.id)) { $errs += "Tenant-Profil ohne interne id."; continue } if ($seenIds.ContainsKey([string]$t.id)) { $errs += "Tenant-Profil-id nicht eindeutig: $($t.id)"; continue } $seenIds[[string]$t.id] = $true if (-not $t.label -or [string]::IsNullOrWhiteSpace($t.label)) { $errs += "Tenant-Profil '$lbl': Bezeichnung erforderlich." } if (-not $t.tenantId -or $t.tenantId -notmatch '^[0-9a-fA-F-]{36}$') { $errs += "Tenant-Profil '$lbl': Tenant ID muss eine GUID sein." } if (-not $t.clientId -or $t.clientId -notmatch '^[0-9a-fA-F-]{36}$') { $errs += "Tenant-Profil '$lbl': Client ID (Read/Write) muss eine GUID sein." } if ($t.clientIdRo -and ([string]$t.clientIdRo).Trim() -and $t.clientIdRo -notmatch '^[0-9a-fA-F-]{36}$') { $errs += "Tenant-Profil '$lbl': Client ID (Read Only) muss eine GUID sein (oder leer)." } } } } else { # Single-Tenant: klassische flache Felder. if (-not $S.connection.tenantId -or $S.connection.tenantId -notmatch '^[0-9a-fA-F-]{36}$') { $errs += "Tenant ID muss eine GUID sein." } if (-not $S.connection.clientId -or $S.connection.clientId -notmatch '^[0-9a-fA-F-]{36}$') { $errs += "Client ID (Read/Write) muss eine GUID sein." } if ($S.connection.clientIdRo -and $S.connection.clientIdRo.Trim() -and $S.connection.clientIdRo -notmatch '^[0-9a-fA-F-]{36}$') { $errs += "Client ID (Read Only) muss eine GUID sein (oder leer lassen)." } } if (-not $S.connection.scopes -or @($S.connection.scopes).Count -eq 0) { $errs += "Mindestens ein Scope erforderlich." } $deptPrefixes = Get-DepartmentPrefixes -Settings $S if ($deptPrefixes.Count -gt 0) { $bad = @($deptPrefixes | Where-Object { $_.Trim().Length -lt 2 }) if ($bad.Count -gt 0) { $errs += "Abteilungs-Praefixe muessen jeweils mindestens 2 Zeichen lang sein." } } $allowedUserFields = @('displayName','userPrincipalName','mail','department') $bad = @($S.userSearch.fields | Where-Object { $_ -notin $allowedUserFields }) if ($bad.Count -gt 0) { $errs += "Unbekannte User-Search-Felder: $($bad -join ', ')" } if (-not $S.userSearch.fields -or @($S.userSearch.fields).Count -eq 0) { $errs += "Mindestens ein User-Such-Feld erforderlich." } if (-not $S.requiredGroupNaming.prefix -or -not $S.requiredGroupNaming.suffix) { $errs += "Required-Gruppen-Naming: Praefix und Suffix erforderlich." } if ($S.availableGroupNaming -and (-not $S.availableGroupNaming.prefix -or -not $S.availableGroupNaming.suffix)) { $errs += "Available-Gruppen-Naming: Praefix und Suffix erforderlich." } # Vendor-Registry: jeder Eintrag muss id + detection mit field/match/pattern haben. if ($null -ne $S.vendors) { $allowedFields = @('commandLine','developer','publisher','displayName','notes','owner') $allowedMatches = @('contains','equals','regex','startsWith') $seenIds = @{} foreach ($v in @($S.vendors)) { if (-not $v.id -or [string]::IsNullOrWhiteSpace($v.id)) { $errs += "Vendor: 'id' erforderlich."; continue } if ($seenIds.ContainsKey($v.id)) { $errs += "Vendor '$($v.id)': id ist nicht eindeutig."; continue } $seenIds[$v.id] = $true if (-not $v.displayName) { $errs += "Vendor '$($v.id)': displayName erforderlich." } if (-not $v.detection) { $errs += "Vendor '$($v.id)': detection-Block fehlt."; continue } if ($v.detection.field -notin $allowedFields) { $errs += "Vendor '$($v.id)': detection.field muss eines von $($allowedFields -join '|') sein." } if ($v.detection.match -notin $allowedMatches) { $errs += "Vendor '$($v.id)': detection.match muss eines von $($allowedMatches -join '|') sein." } if (-not $v.detection.pattern -or [string]::IsNullOrWhiteSpace($v.detection.pattern)) { $errs += "Vendor '$($v.id)': detection.pattern erforderlich." } } } # Theme-Farben sind Hex-Strings (#RGB oder #RRGGBB) if ($S.theme -and $S.theme.colors) { foreach ($key in @('brand','accent','required','success','warning','error','rowSelected','detailPanel')) { $val = $S.theme.colors.$key if ($val -and $val -notmatch '^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') { $errs += "Farbe '$key' ist kein gueltiger Hex-Wert." } } } return $errs } function New-AssignmentItem { param( [string]$AppId, [string]$AppName, [string]$AppType, [string]$Type, # "Available" | "Required" [string]$GroupId, [string]$GroupName ) return [pscustomobject]@{ Id = [guid]::NewGuid().ToString() AppId = $AppId AppName = $AppName AppType = $AppType Type = $Type GroupId = $GroupId GroupName = $GroupName AddedAt = (Get-Date).ToString("o") } }