diff --git a/README.md b/README.md index 3dc7279..e4499d6 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,43 @@ Beenden: `Ctrl+C` im Terminal. --- +## Single- vs. Multi-Tenant + +Unter **Settings → Verbindung → Verbindungsmodus** wählst du zwischen: + +| Modus | Verhalten | +|---------------|---------------------------------------------------------------------------| +| Single-Tenant | Klassisch: ein Mandant (Tenant ID + App-Registrierung). Standard. | +| Multi-Tenant | Mehrere Mandanten mit **je eigener App-Registrierung**; Umschalten oben rechts | + +Im Multi-Tenant-Modus legst du pro Mandant ein Profil an (Bezeichnung, Tenant ID, +Client-ID Read/Write, optional Client-ID Read-Only). Der **aktive** Mandant ist +markiert. Über das **Dropdown in der Topbar** wechselst du zwischen den Mandanten — +der Wechsel trennt die aktuelle Verbindung und verbindet **sofort neu** mit dem +gewählten Tenant (Login-Prompt erscheint). Alle Caches werden beim Wechsel geleert. + +> Die Scopes (RW/RO) gelten **global** für alle Profile. Jeder Mandant benötigt +> eine eigene App-Registrierung mit denselben delegierten Berechtigungen und +> (Admin-)Consent im jeweiligen Tenant. Bestehende Single-Tenant-Konfigurationen +> werden verlustfrei übernommen (Umschalten auf Multi bietet an, das vorhandene +> Setup als erstes Profil zu übernehmen). + +### Pro-Tenant-Vorgaben + +Jedes Tenant-Profil kann **eigene Vorgaben** hinterlegen (z. B. Kunde A → `abt-hm-*`, +Kunde B → `dept-*`): + +- **Abteilungs-Präfixe** +- **Required-Gruppen-Naming** (Präfix/Suffix) +- **Available-Gruppen-Naming** (Präfix/Suffix) + +Bleibt ein Feld **leer, gilt die globale Vorgabe** aus den entsprechenden +Einstellungs-Abschnitten (bei Naming greift der Fallback pro Feld einzeln). Beim +Mandantenwechsel werden die Caches geleert, sodass die passenden Vorgaben gegen +den aktiven Tenant wirken. + +--- + ## Verbindungsmodus (Read/Write vs. Read-Only) Der Server probiert beim Verbinden **zuerst die Read/Write-App-ID** (`clientId`). diff --git a/Start.ps1 b/Start.ps1 index 45ba0fc..ba0eeea 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -148,10 +148,12 @@ if ($ClientId) { $script:Settings.connection.clientId = $ClientId } # Globale Konfiguration. TenantId/ClientId/Scopes spiegeln die Settings � # Connect-Endpoint und Co. lesen weiterhin $script:Config, das nach jedem # Settings-Save aktualisiert wird. +# Im Multi-Tenant-Modus kommen TenantId/ClientId aus dem aktiven Profil. +$script:StartupConn = Get-ActiveConnection -Settings $script:Settings $script:Config = @{ - TenantId = $script:Settings.connection.tenantId - ClientId = $script:Settings.connection.clientId - ClientIdRo = $script:Settings.connection.clientIdRo + TenantId = $script:StartupConn.tenantId + ClientId = $script:StartupConn.clientId + ClientIdRo = $script:StartupConn.clientIdRo Scopes = @($script:Settings.connection.scopes) ScopesRo = @($script:Settings.connection.scopesRo) WebRoot = (Join-Path $root "www") diff --git a/src/Api.ps1 b/src/Api.ps1 index 36c6861..2e6ed0b 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -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() diff --git a/src/Models.ps1 b/src/Models.ps1 index 288ac03..5689c5c 100644 --- a/src/Models.ps1 +++ b/src/Models.ps1 @@ -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." diff --git a/www/app.js b/www/app.js index 206e1e9..2fc3e6d 100644 --- a/www/app.js +++ b/www/app.js @@ -47,6 +47,11 @@ async function api(path, options = {}) { let res; try { res = await fetch(path, opts); + } catch (e) { + // "Failed to fetch" = der lokale Server hat nicht geantwortet (Prozess + // beendet/abgestürzt oder PowerShell-Fenster geschlossen). Klartext geben. + if (e && (e.name === 'AbortError')) throw e; + throw new Error('Server nicht erreichbar — läuft das PowerShell-Fenster (Run.cmd) noch? Bitte die App neu starten und erneut versuchen.'); } finally { if (timer) clearTimeout(timer); } @@ -158,6 +163,56 @@ async function refreshStatus() { } } +// Topbar-Mandantenumschalter: nur im Multi-Tenant-Modus sichtbar. +async function refreshTenantSwitcher() { + const sel = document.getElementById('tenantSwitch'); + if (!sel) return; + try { + const t = await api('/api/tenants'); + if (!t.multiTenant || !(t.items || []).length) { + sel.classList.add('hidden'); + sel.innerHTML = ''; + return; + } + sel.innerHTML = t.items.map(it => + `` + ).join(''); + sel.classList.remove('hidden'); + } catch { + sel.classList.add('hidden'); + } +} + +document.getElementById('tenantSwitch')?.addEventListener('change', async e => { + const id = e.target.value; + setLoading('Wechsle Mandant…'); + try { + // Löst server-seitig Disconnect + Reconnect mit dem neuen Tenant aus. + const s = await api('/api/tenants/switch', { method: 'POST', body: { id }, timeoutMs: 120000 }); + State.connected = !!s.connected; + State.account = s.account; + 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?.(); } + switchMainView('apps'); + if (s.connected) { + toast('Verbunden mit ' + (s.tenantLabel || s.account || 'Mandant'), 'ok', 'Mandant gewechselt'); + autoLoadAfterConnect(); + } else { + toast('Umgestellt, aber nicht verbunden.', 'warn'); + } + } catch (err) { + toast('Wechsel fehlgeschlagen: ' + err.message, 'err'); + await refreshStatus(); + } finally { + clearLoading(); + refreshTenantSwitcher(); + } +}); + function applyReadOnlyMode() { document.body.classList.toggle('read-only', State.readOnly); // Falls ein Schreib-Tab aktiv ist beim Wechsel in Read-Only -> Export-Tab zeigen @@ -2554,17 +2609,31 @@ let cgCheckTimer = null; // Naming-Schemas aus Settings holen. Fallback auf Defaults wenn Settings noch // nicht geladen sind (z.B. waehrend des allerersten Init-Renders). +// Aktives Tenant-Profil aus einem Settings-Objekt (oder null im Single-Modus). +function activeTenantProfileFromSettings(s) { + const c = s && s.connection; + if (!c || !c.multiTenant || !Array.isArray(c.tenants) || !c.tenants.length) return null; + return c.tenants.find(t => t.id === c.activeTenantId) || c.tenants[0]; +} + function getNamingFor(intent) { const s = SettingsState && SettingsState.current; - const block = intent === 'available' - ? (s && s.availableGroupNaming) - : (s && s.requiredGroupNaming); + const isAvail = intent === 'available'; const defPrefix = 'intune-win-app-'; - const defSuffix = intent === 'available' ? '-available' : '-required'; - return { - prefix: (block && block.prefix) || defPrefix, - suffix: (block && block.suffix) || defSuffix, - }; + const defSuffix = isAvail ? '-available' : '-required'; + const gBlock = s && (isAvail ? s.availableGroupNaming : s.requiredGroupNaming); + let prefix = (gBlock && gBlock.prefix) || defPrefix; + let suffix = (gBlock && gBlock.suffix) || defSuffix; + // Aktives Tenant-Profil kann pro Feld überschreiben (leer = globale Vorgabe). + const prof = activeTenantProfileFromSettings(s); + if (prof) { + const pBlock = isAvail ? prof.availableGroupNaming : prof.requiredGroupNaming; + if (pBlock) { + if (pBlock.prefix && String(pBlock.prefix).trim()) prefix = pBlock.prefix; + if (pBlock.suffix && String(pBlock.suffix).trim()) suffix = pBlock.suffix; + } + } + return { prefix, suffix }; } function appSlug(appName) { @@ -3405,7 +3474,7 @@ async function openHelpModal() { try { const v = await api('/api/version'); const el = document.getElementById('helpVersion'); - if (el) el.textContent = `Version ${v.version} · Build ${v.build}`; + if (el) el.textContent = `Version ${v.version} · Build ${v.build} · Entwickelt von WendeIT – Marco Wende`; } catch {} if (helpLoaded) return; const contentEl = document.getElementById('helpContent'); @@ -3756,17 +3825,12 @@ function rebuildCategoryFilterOptions() { function isSetupIncomplete(s) { if (!s) return true; const c = s.connection || {}; - const d = s.departments || {}; const tenant = (c.tenantId || '').trim(); const client = (c.clientId || '').trim(); - // Praefixe: 'prefixes'-Array bevorzugt, sonst alter Single-String 'prefix'. - // Setup ist komplett, wenn mind. ein nicht-leerer Praefix (>= 2 Zeichen) existiert. - let hasPrefix = false; - if (Array.isArray(d.prefixes)) { - for (const p of d.prefixes) { if (p && String(p).trim().length >= 2) { hasPrefix = true; break; } } - } - if (!hasPrefix && d.prefix && String(d.prefix).trim().length >= 2) { hasPrefix = true; } - return !tenant || !client || !hasPrefix; + // Nur Tenant ID + Client ID sind Pflicht fuer eine Verbindung. Abteilungs- + // Praefixe und RPA-Gruppen sind optional — fehlen sie, zeigen die jeweiligen + // Tabs lediglich einen Konfigurations-Hinweis (kein harter Setup-Blocker). + return !tenant || !client; } function applySetupNeededUI(needed) { @@ -3856,15 +3920,7 @@ function renderSettingsTestResult(res) { } else { lines.push(`
${miss}http://localhost.http://localhost.displayName-Werte der Gruppen, die im RPA-Tab angezeigt werden. Eine pro Zeile.