Files
wendeIT-scripting/Intune/Intune-App-Manager-Web/src/Models.ps1
T
2026-06-17 07:40:52 +02:00

296 lines
13 KiB
PowerShell

# 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]@{
tenantId = ""
clientId = ""
# 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")
}
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 = @()
}
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. Bevorzugt das neue
# 'prefixes'-Array; faellt sonst auf den alten Single-String 'prefix'
# zurueck (Rueckwaerts-Kompatibilitaet mit existierenden settings.json).
# Liefert immer ein String-Array (getrimmt, dedupliziert, ohne leere
# Eintraege), ggf. leer wenn nichts konfiguriert ist.
param($Settings)
$out = [System.Collections.Generic.List[string]]::new()
if ($null -eq $Settings -or $null -eq $Settings.departments) { return ,$out.ToArray() }
$d = $Settings.departments
$candidates = @()
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 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 = @()
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 muss eine GUID sein."
}
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 -eq 0) {
$errs += "Mindestens ein Abteilungs-Praefix erforderlich."
} else {
$bad = @($deptPrefixes | Where-Object { $_.Trim().Length -lt 2 })
if ($bad.Count -gt 0) { $errs += "Abteilungs-Praefixe muessen jeweils mindestens 2 Zeichen lang sein." }
}
if (-not $S.rpa.groupNames -or @($S.rpa.groupNames).Count -eq 0) {
$errs += "Mindestens eine RPA-Gruppe erforderlich."
}
$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")
}
}