Neu: - Administrative Vorlagen (ADMX / groupPolicyConfigurations) als vierter Policy-Typ fuer Export/Import (GET /api/policies/administrativetemplate) - Zuweisung direkt nach Import: pro Policy Include-/Exclude-Gruppen (POST /api/policies/assign, typ-spezifische /assign-Action) - Git-Snapshot: versioniertes Policy-Backup in lokalen Git-Ordner (Settings-Sektion policyBackup) - Import akzeptiert Git-Snapshot-Dateien (policyType neben exportType) Behoben: - Settings-Catalog-Import schema-konform: Collection-Properties immer als Array (repariert PS-5.1-Roundtrip), null -> [], Wrapper-@odata.type, read-only id entfernt - Import-Phantom-Fehler (@($null)-Geisterdurchlauf im Archiv-Pfad) - Frontend-Cache-Reset beim Tenant-Wechsel (resetClientState) - WAM-Anmeldefenster zuverlaessig im Vordergrund (AttachThreadInput) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
439 lines
21 KiB
PowerShell
439 lines
21 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]@{
|
|
# 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")
|
|
}
|
|
}
|