Multi-Tenant-Umschaltung + Pro-Tenant-Vorgaben, RPA deaktiviert

- Multi-Tenant: Settings-Schalter Single/Multi, Profile mit je eigener
  App-Registrierung, Topbar-Umschalter mit Sofort-Reconnect; verlustfreie
  Migration (Get-ActiveConnection/-TenantProfile, /api/tenants[/switch]).
- Pro-Tenant-Overrides mit globalem Fallback: Abteilungs-Praefixe sowie
  Required-/Available-Gruppen-Naming (Get-DepartmentPrefixes/Get-GroupNaming
  tenant-bewusst; New-GroupEndpoint nutzt Resolver).
- RPA komplett deaktiviert: Mode-Tab, globaler Settings-Abschnitt und
  Test-Anzeige entfernt.
- Setup-Zwang gelockert: nur Tenant ID + Client ID Pflicht; Abteilungs-
  Praefixe optional (kein harter Setup-Blocker mehr).
- api(): "Failed to fetch" -> klare Meldung "Server nicht erreichbar…".
- Hilfe-Footer: "Entwickelt von WendeIT – Marco Wende".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:16:34 +02:00
co-authored by Claude Opus 4.8
parent f36e9c5ba0
commit 37ae32bb94
7 changed files with 638 additions and 105 deletions
+104 -21
View File
@@ -47,6 +47,9 @@ function Invoke-ApiHandler {
"POST /api/assignments/apply" { return Invoke-ApplyEndpoint -Body $Body }
"GET /api/tenants" { return Get-TenantsEndpoint }
"POST /api/tenants/switch" { return Switch-TenantEndpoint -Body $Body }
"GET /api/policies/compliance" { return Get-CompliancePoliciesEndpoint }
"GET /api/policies/configuration" { return Get-ConfigurationProfilesEndpoint }
"GET /api/policies/settingscatalog" { return Get-SettingsCatalogPoliciesEndpoint }
@@ -167,9 +170,11 @@ function Get-SettingsEndpoint {
function Sync-ConfigFromSettings {
# $script:Config spiegelt die settings.connection-Werte. Wird nach jedem
# Save aufgerufen damit Connect-Aufrufe sofort die neuen IDs verwenden.
$script:Config.TenantId = $script:Settings.connection.tenantId
$script:Config.ClientId = $script:Settings.connection.clientId
$script:Config.ClientIdRo = $script:Settings.connection.clientIdRo
# Im Multi-Tenant-Modus kommen TenantId/ClientId aus dem aktiven Profil.
$active = Get-ActiveConnection -Settings $script:Settings
$script:Config.TenantId = $active.tenantId
$script:Config.ClientId = $active.clientId
$script:Config.ClientIdRo = $active.clientIdRo
$script:Config.Scopes = @($script:Settings.connection.scopes)
$script:Config.ScopesRo = @($script:Settings.connection.scopesRo)
# Policy Export/Import braucht den Configuration-Scope. Immer sicherstellen —
@@ -207,17 +212,26 @@ function Save-SettingsEndpoint {
# Vergleich altes vs. neues Setting — Cache nur leeren wo wirklich noetig.
$old = $script:Settings
# Aktive Verbindung vergleichen (deckt Single-Felder UND das aktive
# Multi-Tenant-Profil ab), plus Moduswechsel Single<->Multi.
$activeOld = Get-ActiveConnection -Settings $old
$activeNew = Get-ActiveConnection -Settings $merged
$multiOld = ($old.connection.PSObject.Properties['multiTenant'] -and [bool]$old.connection.multiTenant)
$multiNew = ($merged.connection.PSObject.Properties['multiTenant'] -and [bool]$merged.connection.multiTenant)
$connectionChanged = (
$merged.connection.tenantId -ne $old.connection.tenantId -or
$merged.connection.clientId -ne $old.connection.clientId -or
(($merged.connection.scopes -join "|") -ne ($old.connection.scopes -join "|"))
$activeNew.tenantId -ne $activeOld.tenantId -or
$activeNew.clientId -ne $activeOld.clientId -or
$activeNew.clientIdRo -ne $activeOld.clientIdRo -or
(($merged.connection.scopes -join "|") -ne ($old.connection.scopes -join "|")) -or
($multiNew -ne $multiOld)
)
# Normalisierte Praefix-Listen vergleichen (deckt sowohl 'prefixes' als auch
# den alten Single-String 'prefix' ab; Reihenfolge ignoriert, Case ignoriert).
$deptOld = @(Get-DepartmentPrefixes -Settings $old) | Sort-Object -Property { $_.ToLowerInvariant() }
$deptNew = @(Get-DepartmentPrefixes -Settings $merged) | Sort-Object -Property { $_.ToLowerInvariant() }
$deptChanged = (($deptOld -join '|') -ne ($deptNew -join '|'))
$rpaChanged = (($merged.rpa.groupNames -join "|") -ne ($old.rpa.groupNames -join "|"))
# Tenant-bewusst: vergleicht die aufgeloesten RPA-Namen (Profil-Override oder global).
$rpaChanged = ((@(Get-RpaGroupNames -Settings $merged) -join "|") -ne (@(Get-RpaGroupNames -Settings $old) -join "|"))
$userSearchChanged = (
(($merged.userSearch.fields -join "|") -ne ($old.userSearch.fields -join "|"))
)
@@ -573,16 +587,24 @@ function Get-StatusEndpoint {
error = $cs.Error
}
}
$activeConn = Get-ActiveConnection -Settings $script:Settings
$activeId = ""
if ($script:Settings.connection.PSObject.Properties['activeTenantId']) {
$activeId = [string]$script:Settings.connection.activeTenantId
}
return @{
connected = $script:State.Connected
account = $script:State.Account
tenantId = $script:State.TenantId
readOnly = [bool]$script:State.ReadOnly
groupsLoaded = $script:State.Groups.Count
rpaLoaded = $script:State.RpaGroups.Count
appsLoaded = $script:State.Apps.Count
sessionCount = $script:State.Session.Count
connect = $connect
connected = $script:State.Connected
account = $script:State.Account
tenantId = $script:State.TenantId
readOnly = [bool]$script:State.ReadOnly
groupsLoaded = $script:State.Groups.Count
rpaLoaded = $script:State.RpaGroups.Count
appsLoaded = $script:State.Apps.Count
sessionCount = $script:State.Session.Count
connect = $connect
multiTenant = (Test-ConnectionMultiTenant)
tenantLabel = $activeConn.label
activeTenantId = $activeId
}
}
@@ -1021,6 +1043,66 @@ function Test-Connected {
return $null
}
# ============================================================
# Multi-Tenant
# ============================================================
function Test-ConnectionMultiTenant {
$c = $script:Settings.connection
return ($c -and $c.PSObject.Properties['multiTenant'] -and [bool]$c.multiTenant)
}
function Get-TenantsEndpoint {
# Liste der Tenant-Profile fuer den Topbar-Umschalter. Client-IDs werden
# nicht mitgeliefert (fuer die Anzeige nicht noetig).
$c = $script:Settings.connection
$isMulti = Test-ConnectionMultiTenant
$items = @()
if ($isMulti -and $c.PSObject.Properties['tenants']) {
foreach ($t in @($c.tenants | Where-Object { $_ })) {
$items += @{
id = [string]$t.id
label = [string]$t.label
tenantId = [string]$t.tenantId
hasRo = [bool]($t.clientIdRo -and ([string]$t.clientIdRo).Trim())
}
}
}
$activeId = if ($c.PSObject.Properties['activeTenantId']) { [string]$c.activeTenantId } else { "" }
if ($isMulti -and $items.Count -gt 0 -and -not (@($items | Where-Object { $_.id -eq $activeId }).Count)) {
$activeId = $items[0].id
}
return @{ multiTenant = $isMulti; activeId = $activeId; items = @($items) }
}
function Switch-TenantEndpoint {
param($Body)
if (-not (Test-ConnectionMultiTenant)) {
return @{ __status = 400; error = "Multi-Tenant-Modus ist nicht aktiv." }
}
$id = if ($Body) { [string]$Body.id } else { "" }
if ([string]::IsNullOrWhiteSpace($id)) { return @{ __status = 400; error = "Tenant-id fehlt." } }
$c = $script:Settings.connection
$tenants = @()
if ($c.PSObject.Properties['tenants']) { $tenants = @($c.tenants | Where-Object { $_ }) }
$profile = $tenants | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $profile) { return @{ __status = 404; error = "Tenant-Profil nicht gefunden." } }
# Aktives Profil setzen + persistieren, Config spiegeln.
$script:Settings.connection.activeTenantId = $id
try { Write-Settings -Settings $script:Settings } catch {
Write-Host "[TENANT] Speichern des aktiven Profils fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow
}
Sync-ConfigFromSettings
# Bestehende Verbindung trennen (raeumt alle Tenant-Caches ab), dann sofort
# mit dem neuen Tenant neu verbinden.
Invoke-DisconnectEndpoint | Out-Null
Write-Host "[TENANT] Wechsel zu '$([string]$profile.label)' ($($profile.tenantId))" -ForegroundColor Cyan
return Invoke-ConnectEndpoint
}
function Get-AppCategoryNames {
# Extrahiert die Kategorie-Namen aus dem rohen Graph-Categories-Feld.
# Robust gegen alle Source-Types die MgGraph/Invoke-MgGraphRequest in
@@ -1152,7 +1234,8 @@ function Get-RpaGroupsEndpoint {
$err = Test-Connected
if ($err) { return $err }
$rpaNames = @($script:Settings.rpa.groupNames | Where-Object { $_ })
# Tenant-bewusst: aktives Profil kann eigene RPA-Gruppen definieren, sonst global.
$rpaNames = @(Get-RpaGroupNames -Settings $script:Settings)
if ($rpaNames.Count -eq 0) {
# Settings leer -> ehrlich melden, das Frontend zeigt einen Konfig-Hinweis.
return @{ items = @(); count = 0; notConfigured = $true }
@@ -1211,10 +1294,10 @@ function New-GroupEndpoint {
return @{ __status = 400; error = "Intent muss 'required' oder 'available' sein" }
}
$namingObj = if ($intent -eq "available") { $script:Settings.availableGroupNaming } else { $script:Settings.requiredGroupNaming }
$defaultSuffix = if ($intent -eq "available") { "-available" } else { "-required" }
$namingPrefix = if ($namingObj -and $namingObj.prefix) { $namingObj.prefix } else { "intune-win-app-" }
$namingSuffix = if ($namingObj -and $namingObj.suffix) { $namingObj.suffix } else { $defaultSuffix }
# Naming tenant-bewusst aufloesen (aktives Profil kann ueberschreiben).
$naming = Get-GroupNaming -Settings $script:Settings -Intent $intent
$namingPrefix = $naming.prefix
$namingSuffix = $naming.suffix
$cleaned = if ($customName) { Format-GroupNameSlug -Name $customName } else { Format-GroupNameSlug -Name $appName }
$displayName = "$namingPrefix$cleaned$namingSuffix".ToLower()
+153 -17
View File
@@ -44,6 +44,10 @@ function Get-DefaultSettings {
# 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 = ""
@@ -53,6 +57,10 @@ function Get-DefaultSettings {
# 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).
@@ -164,18 +172,27 @@ function Merge-Settings {
}
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.
# 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 -or $null -eq $Settings.departments) { return ,$out.ToArray() }
$d = $Settings.departments
if ($null -eq $Settings) { return ,$out.ToArray() }
$candidates = @()
if ($d.PSObject.Properties['prefixes']) { $candidates += @($d.prefixes) }
if ($d.PSObject.Properties['prefix'] -and $d.prefix) { $candidates += @([string]$d.prefix) }
# 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 }
@@ -189,6 +206,100 @@ function Get-DepartmentPrefixes {
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
@@ -216,14 +327,39 @@ 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 (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)."
$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."