5 Commits
Author SHA1 Message Date
marcoandClaude Opus 4.8 9a61cad54b CI: Gitea-Upload-Schritt loggt Token-Laenge zur Diagnose
Build & Release MSI / build-msi (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 00:37:54 +02:00
marcoandClaude Opus 4.8 18e6a597cc v0.1.26 — Multi-Tenant, Pro-Tenant-Vorgaben, RPA entfernt
Build & Release MSI / build-msi (push) Canceled after 0s
Version-Bump (Start.ps1, build-local.ps1) + CHANGELOG-Eintrag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 00:29:16 +02:00
marcoandClaude Opus 4.8 30d605703b CI: gebaute MSI zusaetzlich als Gitea-Release-Asset spiegeln
Neuer Schritt in release.yml lädt die MSI nach dem GitHub-Release per Gitea-API
(Release per Tag holen/anlegen, gleichnamiges Asset ersetzen, hochladen).
Benoetigt GitHub-Secret GITEA_TOKEN; ohne Token ueberspringt sich der Schritt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 00:27:47 +02:00
marcoandClaude Opus 4.8 37ae32bb94 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>
2026-08-19 19:16:34 +02:00
marcoandClaude Opus 4.8 f36e9c5ba0 Policy-Export: mehrere Policies als Einzeldateien statt Bundle
Bei mehreren ausgewaehlten Policies wird jede als eigene JSON-Datei
heruntergeladen (statt einer gebuendelten Datei). 150ms Pause zwischen den
Downloads gegen verschluckte Browser-Downloads; Dateinamen bei gleichem
Anzeigenamen automatisch eindeutig. Import liest alte Bundles weiterhin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 14:33:24 +02:00
10 changed files with 721 additions and 119 deletions
+49
View File
@@ -107,3 +107,52 @@ jobs:
- `Microsoft.Graph.Authentication` Modul (wird beim ersten Start automatisch angeboten)
files: "IntuneManager-${{ steps.ver.outputs.TAG }}.msi"
generate_release_notes: false
- name: Mirror MSI to Gitea release
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_BASE: https://git.wende.it/api/v1
GITEA_OWNER: marco
GITEA_REPO: Intune-Manager
TAG: ${{ steps.ver.outputs.TAG }}
MSI_NAME: ${{ steps.build.outputs.MSI_NAME }}
COMMIT_SHA: ${{ github.sha }}
run: |
$tokLen = if ([string]::IsNullOrEmpty($env:GITEA_TOKEN)) { 0 } else { $env:GITEA_TOKEN.Length }
Write-Host "Diagnose: GITEA_TOKEN Laenge = $tokLen (0 = Secret erreicht den Lauf nicht)"
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
Write-Host "::warning::GITEA_TOKEN leer (Laenge=$tokLen) — Gitea-Upload uebersprungen. Muss ein REPOSITORY-Secret sein unter Settings > Secrets and variables > Actions (Tab 'Secrets', NICHT 'Variables'), Name exakt GITEA_TOKEN, im Repo mwende/Intune-Manager."
exit 0
}
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
$repoUrl = "$($env:GITEA_BASE)/repos/$($env:GITEA_OWNER)/$($env:GITEA_REPO)"
# Release per Tag suchen; sonst anlegen (Tag wird bei Bedarf am Commit erzeugt).
$rel = $null
try { $rel = Invoke-RestMethod -Headers $headers -Uri "$repoUrl/releases/tags/$($env:TAG)" -Method GET } catch { $rel = $null }
if (-not $rel) {
$body = @{
tag_name = $env:TAG
target_commitish = $env:COMMIT_SHA
name = "Intune Manager $($env:TAG)"
body = "Automatisch aus der GitHub-CI gespiegelt."
} | ConvertTo-Json
$rel = Invoke-RestMethod -Headers $headers -Uri "$repoUrl/releases" -Method POST -ContentType 'application/json' -Body $body
}
$relId = $rel.id
# Vorhandenes Asset gleichen Namens entfernen (idempotent bei Re-Runs).
try {
$assets = Invoke-RestMethod -Headers $headers -Uri "$repoUrl/releases/$relId/assets" -Method GET
foreach ($a in @($assets)) {
if ($a.name -eq $env:MSI_NAME) {
Invoke-RestMethod -Headers $headers -Uri "$repoUrl/releases/$relId/assets/$($a.id)" -Method DELETE | Out-Null
}
}
} catch {}
# MSI als Attachment hochladen (multipart, Feldname 'attachment').
$assetUri = "$repoUrl/releases/$relId/assets?name=$([uri]::EscapeDataString($env:MSI_NAME))"
Invoke-RestMethod -Headers $headers -Uri $assetUri -Method POST -Form @{ attachment = Get-Item -LiteralPath $env:MSI_NAME } | Out-Null
Write-Host "MSI '$($env:MSI_NAME)' -> Gitea-Release '$($env:TAG)' hochgeladen."
+20
View File
@@ -5,6 +5,26 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan
---
## [0.1.26] - 2026-08-21
Multi-Tenant, Pro-Tenant-Vorgaben, RPA entfernt.
### Neu
- **Multi-Tenant-Modus** (umschaltbar in den Einstellungen, Single/Multi): mehrere Mandanten mit **je eigener App-Registrierung**; Umschalter in der Topbar mit **Sofort-Reconnect**. Bestehende Single-Tenant-Konfiguration wird verlustfrei übernommen.
- **Pro-Tenant-Vorgaben** mit globalem Fallback: **Abteilungs-Präfixe** sowie **Required-/Available-Gruppen-Naming** je Profil überschreibbar (leer = globale Vorgabe).
- **Policy-Export als Einzeldateien** bei Mehrfachauswahl (statt Bundle).
- **CI**: gebaute MSI wird zusätzlich als **Gitea-Release-Asset** gespiegelt (Secret `GITEA_TOKEN`).
- Hilfe-Fenster: Hinweis „Entwickelt von WendeIT – Marco Wende".
### Geändert / Entfernt
- **RPA-Funktion entfernt**: RPA-Tab, RPA-Einstellungen und RPA-Test-Anzeige sind nicht mehr vorhanden.
- Setup-Zwang gelockert: nur **Tenant ID + Client ID** sind Pflicht; Abteilungs-Präfixe sind optional.
- Klarere Fehlermeldung bei nicht erreichbarem lokalen Server (statt „Failed to fetch").
---
## [0.1.25] - 2026-08-19
Policy Export/Import und Geräte-Tab.
+37
View File
@@ -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`).
+6 -4
View File
@@ -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")
@@ -186,7 +188,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen
}
$script:ToolVersion = "0.1.25"
$script:ToolVersion = "0.1.26"
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host ""
+1 -1
View File
@@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") {
$env:DOTNET_ROOT = "C:\Program Files\dotnet"
}
$version = "0.1.25"
$version = "0.1.26"
$src = "\\Mac\Home\ClaudePRJ\Intune Manager"
$stage = "$env:TEMP\IntuneManagerStage"
$installerDir = "$src\installer"
+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."
+258 -50
View File
@@ -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 =>
`<option value="${escapeHtml(it.id)}" ${it.id === t.activeId ? 'selected' : ''}>${escapeHtml(it.label || it.tenantId)}</option>`
).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(`<div class="set-test-line set-test-fail">${fail} Abteilungs-Gruppen-Lookup fehlgeschlagen: ${escapeHtml(d.error || '')}</div>`);
}
const r = res.rpa || {};
if (r.ok) {
lines.push(`<div class="set-test-line">${ok} RPA-Gruppen: <strong>${(r.found || []).length}</strong> von <strong>${r.expected}</strong> gefunden</div>`);
} else if (r.reason === 'not_configured') {
lines.push(`<div class="set-test-line set-test-warn">${fail} Keine RPA-Gruppen konfiguriert (optional — der RPA-Tab bleibt dann leer).</div>`);
} else {
const miss = (r.missing || []).map(escapeHtml).join(', ');
lines.push(`<div class="set-test-line set-test-fail">${fail} RPA-Gruppen: ${(r.found || []).length} von ${r.expected} gefunden. Fehlt: <code>${miss}</code></div>`);
}
// RPA-Funktion ist deaktiviert — Test-Ergebnis dazu wird nicht angezeigt.
const u = res.userSearch || {};
if (u.ok) {
lines.push(`<div class="set-test-line">${ok} Benutzer-Suche: ok (${(u.fields || []).join(', ')})</div>`);
@@ -3887,6 +3943,136 @@ function renderSettingsTestResult(res) {
return lines.join('');
}
// --- Multi-Tenant Profil-Editor (Einstellungen) ---
function tenantNewId() {
return (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: 't-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8);
}
function applyConnModeUI(mode) {
const multi = mode === 'multi';
document.getElementById('setSingleConn').classList.toggle('hidden', multi);
document.getElementById('setMultiConn').classList.toggle('hidden', !multi);
}
function tenantRowHtml(t, active) {
const id = t.id || tenantNewId();
return `<div class="tenant-row" data-id="${escapeHtml(id)}">
<div class="tenant-row-head">
<label class="tenant-active"><input type="radio" name="tenantActive" value="${escapeHtml(id)}" ${active ? 'checked' : ''}><span>Aktiv</span></label>
<input type="text" class="input tenant-label" placeholder="Bezeichnung (z.B. Kunde A)" value="${escapeHtml(t.label || '')}" autocomplete="off">
<button type="button" class="btn btn-danger btn-sm tenant-remove" title="Profil entfernen">✕</button>
</div>
<div class="tenant-row-grid">
<input type="text" class="input mono tenant-tid" placeholder="Tenant ID (GUID)" value="${escapeHtml(t.tenantId || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-cid" placeholder="Client ID Read/Write (GUID)" value="${escapeHtml(t.clientId || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-cidro" placeholder="Client ID Read-Only (optional)" value="${escapeHtml(t.clientIdRo || '')}" spellcheck="false" autocomplete="off">
</div>
<div class="tenant-row-overrides">
<label class="tenant-sub-lbl">Abteilungs-Präfixe <span class="tenant-sub-hint">(leer = globale Vorgabe)</span></label>
<textarea class="input mono tenant-prefixes" rows="2" spellcheck="false" placeholder="eine pro Zeile, z.B. abt-hm">${escapeHtml((t.prefixes || []).join('\n'))}</textarea>
<label class="tenant-sub-lbl">Required-Gruppen-Naming <span class="tenant-sub-hint">(leer = globale Vorgabe)</span></label>
<div class="tenant-naming-grid">
<input type="text" class="input mono tenant-req-prefix" placeholder="Präfix" value="${escapeHtml((t.requiredGroupNaming && t.requiredGroupNaming.prefix) || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-req-suffix" placeholder="Suffix" value="${escapeHtml((t.requiredGroupNaming && t.requiredGroupNaming.suffix) || '')}" spellcheck="false" autocomplete="off">
</div>
<label class="tenant-sub-lbl">Available-Gruppen-Naming <span class="tenant-sub-hint">(leer = globale Vorgabe)</span></label>
<div class="tenant-naming-grid">
<input type="text" class="input mono tenant-avail-prefix" placeholder="Präfix" value="${escapeHtml((t.availableGroupNaming && t.availableGroupNaming.prefix) || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-avail-suffix" placeholder="Suffix" value="${escapeHtml((t.availableGroupNaming && t.availableGroupNaming.suffix) || '')}" spellcheck="false" autocomplete="off">
</div>
</div>
</div>`;
}
const TENANT_EMPTY_HINT = '<div class="form-hint tenant-empty">Noch keine Tenants. Füge unten einen hinzu.</div>';
function renderTenantRows(tenants, activeId) {
const el = document.getElementById('setTenantList');
const list = Array.isArray(tenants) ? tenants.filter(Boolean) : [];
let active = activeId;
if (!list.some(t => t.id === active) && list.length) active = list[0].id;
el.innerHTML = list.length ? list.map(t => tenantRowHtml(t, t.id === active)).join('') : TENANT_EMPTY_HINT;
wireTenantRowEvents();
}
function wireTenantRowEvents() {
document.querySelectorAll('#setTenantList .tenant-remove').forEach(btn => {
btn.onclick = () => {
const row = btn.closest('.tenant-row');
const wasActive = row.querySelector('input[name="tenantActive"]').checked;
row.remove();
if (wasActive) {
const first = document.querySelector('#setTenantList input[name="tenantActive"]');
if (first) first.checked = true;
}
if (!document.querySelector('#setTenantList .tenant-row')) {
document.getElementById('setTenantList').innerHTML = TENANT_EMPTY_HINT;
}
};
});
}
function addTenantRow() {
const el = document.getElementById('setTenantList');
if (!el.querySelector('.tenant-row')) el.innerHTML = '';
const id = tenantNewId();
const isFirst = !el.querySelector('.tenant-row');
el.insertAdjacentHTML('beforeend', tenantRowHtml({ id }, isFirst));
wireTenantRowEvents();
el.querySelector(`.tenant-row[data-id="${CSS.escape(id)}"] .tenant-label`)?.focus();
}
function tenantSplitLines(txt) {
return (txt || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
}
function readTenantRows() {
const tenants = [];
let activeTenantId = '';
document.querySelectorAll('#setTenantList .tenant-row').forEach(row => {
const id = row.dataset.id;
tenants.push({
id,
label: row.querySelector('.tenant-label').value.trim(),
tenantId: row.querySelector('.tenant-tid').value.trim(),
clientId: row.querySelector('.tenant-cid').value.trim(),
clientIdRo: row.querySelector('.tenant-cidro').value.trim(),
// Pro-Tenant-Overrides; leer = globale Vorgabe gilt.
prefixes: tenantSplitLines(row.querySelector('.tenant-prefixes').value),
requiredGroupNaming: {
prefix: row.querySelector('.tenant-req-prefix').value.trim(),
suffix: row.querySelector('.tenant-req-suffix').value.trim(),
},
availableGroupNaming: {
prefix: row.querySelector('.tenant-avail-prefix').value.trim(),
suffix: row.querySelector('.tenant-avail-suffix').value.trim(),
},
});
if (row.querySelector('input[name="tenantActive"]').checked) activeTenantId = id;
});
if (!activeTenantId && tenants.length) activeTenantId = tenants[0].id;
return { tenants, activeTenantId };
}
document.getElementById('setConnMode')?.addEventListener('change', e => {
applyConnModeUI(e.target.value);
// Beim erstmaligen Wechsel auf Multi ohne Profile: aus den Single-Feldern ein Profil vorbefüllen
if (e.target.value === 'multi' && !document.querySelector('#setTenantList .tenant-row')) {
const tid = document.getElementById('setTenantId').value.trim();
const cid = document.getElementById('setClientId').value.trim();
if (tid || cid) {
renderTenantRows([{
id: tenantNewId(), label: 'Standard',
tenantId: tid, clientId: cid,
clientIdRo: document.getElementById('setClientIdRo').value.trim(),
}], null);
}
}
});
document.getElementById('btnAddTenant')?.addEventListener('click', addTenantRow);
function fillSettingsForm(s) {
const c = s.connection || {};
document.getElementById('setTenantId').value = c.tenantId || '';
@@ -3895,6 +4081,12 @@ function fillSettingsForm(s) {
document.getElementById('setScopes').value = (c.scopes || []).join('\n');
document.getElementById('setScopesRo').value = (c.scopesRo || []).join('\n');
// Verbindungsmodus + Multi-Tenant-Profile
const mode = c.multiTenant ? 'multi' : 'single';
document.getElementById('setConnMode').value = mode;
renderTenantRows(Array.isArray(c.tenants) ? c.tenants : [], c.activeTenantId || '');
applyConnModeUI(mode);
// Backward-compat: lese 'prefixes' (Array) bevorzugt, falle sonst auf
// alten Single-String 'prefix' zurueck. Beide werden in die Textarea
// gemerged (gleiche Logik wie Get-DepartmentPrefixes im Backend).
@@ -3911,7 +4103,6 @@ function fillSettingsForm(s) {
}
document.getElementById('setDeptPrefixes').value = out.join('\n');
})();
document.getElementById('setRpaGroups').value = ((s.rpa && s.rpa.groupNames) || []).join('\n');
const fields = (s.userSearch && s.userSearch.fields) || [];
document.getElementById('setUsfDisplayName').checked = fields.includes('displayName');
@@ -3972,14 +4163,32 @@ function readSettingsForm() {
if (document.getElementById('setUsfMail').checked) fields.push('mail');
if (document.getElementById('setUsfDepartment').checked) fields.push('department');
const mode = document.getElementById('setConnMode').value;
const multiTenant = mode === 'multi';
const connection = {
multiTenant,
scopes: splitLines(document.getElementById('setScopes').value),
scopesRo: splitLines(document.getElementById('setScopesRo').value),
};
if (multiTenant) {
const { tenants, activeTenantId } = readTenantRows();
connection.tenants = tenants;
connection.activeTenantId = activeTenantId;
// Aktives Profil in die flachen Felder spiegeln (Rückwärts-Kompatibilität;
// Status-Anzeige und evtl. Alt-Code lesen weiter connection.tenantId/clientId).
const active = tenants.find(t => t.id === activeTenantId) || tenants[0] || {};
connection.tenantId = active.tenantId || '';
connection.clientId = active.clientId || '';
connection.clientIdRo = active.clientIdRo || '';
} else {
connection.tenantId = document.getElementById('setTenantId').value.trim();
connection.clientId = document.getElementById('setClientId').value.trim();
connection.clientIdRo = document.getElementById('setClientIdRo').value.trim();
// tenants/activeTenantId nicht mitschicken -> Merge behält vorhandene Profile.
}
return {
connection: {
tenantId: document.getElementById('setTenantId').value.trim(),
clientId: document.getElementById('setClientId').value.trim(),
clientIdRo: document.getElementById('setClientIdRo').value.trim(),
scopes: splitLines(document.getElementById('setScopes').value),
scopesRo: splitLines(document.getElementById('setScopesRo').value),
},
connection,
departments: {
// Neuer Listen-Form: Praefixe aus Textarea. Alten Single-String 'prefix'
// beim Save auf leer setzen, damit nicht beide Quellen divergieren —
@@ -3987,9 +4196,6 @@ function readSettingsForm() {
prefixes: splitLines(document.getElementById('setDeptPrefixes').value),
prefix: '',
},
rpa: {
groupNames: splitLines(document.getElementById('setRpaGroups').value),
},
userSearch: {
fields,
},
@@ -4036,6 +4242,7 @@ async function saveSettings() {
try {
const res = await api('/api/settings', { method: 'PUT', body: payload });
SettingsState.current = res.settings;
refreshTenantSwitcher(); // Modus/Profil-Änderungen im Topbar-Umschalter spiegeln
// Theme + Branding sofort live anwenden (kein Reload noetig)
if (res.settings) {
applyThemeColors(res.settings.theme && res.settings.theme.colors);
@@ -4317,6 +4524,7 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => {
console.warn('Settings konnten beim Init nicht geladen werden:', e.message);
}
await refreshStatus();
refreshTenantSwitcher();
if (State.connected) autoLoadAfterConnect();
// First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen.
if (setupNeeded) {
@@ -5660,19 +5868,19 @@ async function polExportSelected() {
if (res.exports) allExports = allExports.concat(res.exports);
}
if (!allExports.length) { toast('Nichts exportiert.', 'warn'); return; }
if (allExports.length === 1) {
polDownloadJson(allExports[0].data, allExports[0].fileName || 'policy.json');
} else {
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '').replace(/-/g, '');
const bundle = {
type: 'IntuneManagerPolicyBundle',
exportDate: new Date().toISOString(),
count: allExports.length,
policies: allExports.map(e => e.data),
};
polDownloadJson(bundle, `intune-policies_${stamp}.json`);
// Jede ausgewählte Policy als eigene JSON-Datei herunterladen.
const usedNames = new Set();
for (let i = 0; i < allExports.length; i++) {
const e = allExports[i];
let fn = e.fileName || `policy_${i + 1}.json`;
// Dateinamens-Kollisionen (z.B. gleicher Anzeigename) eindeutig machen.
if (usedNames.has(fn)) fn = fn.replace(/\.json$/i, '') + `_${i + 1}.json`;
usedNames.add(fn);
polDownloadJson(e.data, fn);
// Kurze Pause, damit der Browser mehrere aufeinanderfolgende Downloads nicht verwirft.
if (i < allExports.length - 1) await new Promise(r => setTimeout(r, 150));
}
toast(`${allExports.length} Policy(s) exportiert (auch im Server-Archiv abgelegt).`, 'ok');
toast(`${allExports.length} Policy(s) als Einzeldatei(en) exportiert (auch im Server-Archiv abgelegt).`, 'ok');
} catch (e) {
toast('Export fehlgeschlagen: ' + e.message, 'err');
} finally {
+41 -26
View File
@@ -53,6 +53,7 @@
<span class="theme-knob"></span>
</span>
</button>
<select id="tenantSwitch" class="tenant-switch hidden" title="Aktiven Mandanten wechseln"></select>
<div id="connStatus" class="conn-pill conn-off" title="Verbindungsstatus">
<span class="dot"></span>
<span class="conn-text">Offline</span>
@@ -80,7 +81,7 @@
Es öffnet sich ein Microsoft-Anmeldefenster. Wähle den gewünschten Account aus.
</p>
<div id="onboardingSetupHint" class="banner-warning hidden">
Konfiguration unvollständig — bitte zuerst <a href="#" id="onboardingOpenSettings">Einstellungen</a> öffnen und Tenant ID, Client ID und Abteilungs-Präfix setzen.
Konfiguration unvollständig — bitte zuerst <a href="#" id="onboardingOpenSettings">Einstellungen</a> öffnen und Tenant ID und Client ID setzen.
</div>
<button id="btnConnect" class="btn btn-primary btn-lg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M11.4 2.4H2.4v9h9v-9zm10.2 0h-9v9h9v-9zm-10.2 10.2H2.4v9h9v-9zm10.2 0h-9v9h9v-9z"/></svg>
@@ -134,7 +135,6 @@
<div class="mode-segment" role="tablist">
<button class="seg active" data-mode="dept" role="tab">Abteilung</button>
<button class="seg" data-mode="rpa" role="tab">RPA</button>
<button class="seg" data-mode="user" role="tab">Benutzer</button>
</div>
@@ -912,7 +912,7 @@
<div id="setupBanner" class="banner-warning hidden">
<strong>Erste Konfiguration noetig.</strong>
Bitte mindestens Tenant ID, Client ID und Abteilungs-Praefix setzen. Danach <em>Verbindung + Lookups testen</em> ganz unten, dann speichern.
Bitte mindestens Tenant ID und Client ID setzen. Abteilungs-Präfixe sind optional. Danach <em>Verbindung + Lookups testen</em> ganz unten, dann speichern.
</div>
<section class="settings-sec">
@@ -921,20 +921,46 @@
<div class="settings-sec-sub">Tenant- und App-Registrierung. Änderungen erzwingen einen Disconnect.</div>
</div>
<div class="form-group">
<label class="lbl" for="setTenantId">Tenant ID (GUID)</label>
<input type="text" id="setTenantId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">Findest du im Entra Admin Center → Übersicht → Tenant-Informationen.</div>
<label class="lbl" for="setConnMode">Verbindungsmodus</label>
<select id="setConnMode" class="input">
<option value="single">Single-Tenant (ein Mandant)</option>
<option value="multi">Multi-Tenant (mehrere Mandanten, Umschalten in der Topbar)</option>
</select>
<div class="form-hint">Im Multi-Tenant-Modus hinterlegst du mehrere Mandanten mit je eigener App-Registrierung und wechselst oben rechts zwischen ihnen.</div>
</div>
<div class="form-group">
<label class="lbl" for="setClientId">Client ID – Read/Write (GUID)</label>
<input type="text" id="setClientId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">App-Registrierung mit Schreibrechten. Erforderlich: <em>Allow public client flows</em> = Ja, Redirect URI <code>http://localhost</code>.</div>
<!-- Single-Tenant Felder -->
<div id="setSingleConn">
<div class="form-group">
<label class="lbl" for="setTenantId">Tenant ID (GUID)</label>
<input type="text" id="setTenantId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">Findest du im Entra Admin Center → Übersicht → Tenant-Informationen.</div>
</div>
<div class="form-group">
<label class="lbl" for="setClientId">Client ID – Read/Write (GUID)</label>
<input type="text" id="setClientId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">App-Registrierung mit Schreibrechten. Erforderlich: <em>Allow public client flows</em> = Ja, Redirect URI <code>http://localhost</code>.</div>
</div>
<div class="form-group">
<label class="lbl" for="setClientIdRo">Client ID – Read Only (GUID, optional)</label>
<input type="text" id="setClientIdRo" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">Separate App-Registrierung ohne Schreibrechte. Nutzer dieser App sehen alle Daten, können aber nichts ändern. Leer lassen wenn nicht benötigt.</div>
</div>
</div>
<div class="form-group">
<label class="lbl" for="setClientIdRo">Client ID – Read Only (GUID, optional)</label>
<input type="text" id="setClientIdRo" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">Separate App-Registrierung ohne Schreibrechte. Nutzer dieser App sehen alle Daten, können aber nichts ändern. Leer lassen wenn nicht benötigt.</div>
<!-- Multi-Tenant Profile -->
<div id="setMultiConn" class="hidden">
<div class="form-group">
<label class="lbl">Tenant-Profile</label>
<div class="form-hint" style="margin-bottom:8px;">Pro Mandant eine eigene App-Registrierung. Der aktive Mandant ist markiert; umschalten geht auch über die Topbar.</div>
<div id="setTenantList" class="tenant-list"></div>
<button type="button" id="btnAddTenant" class="btn btn-secondary btn-sm" style="margin-top:8px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Tenant hinzufügen
</button>
</div>
</div>
<div class="form-group">
<label class="lbl" for="setScopes">Scopes Read/Write (eine pro Zeile)</label>
<textarea id="setScopes" class="input mono" rows="4" spellcheck="false" placeholder="Group.ReadWrite.All&#10;GroupMember.ReadWrite.All&#10;User.Read.All&#10;DeviceManagementApps.ReadWrite.All"></textarea>
@@ -958,17 +984,6 @@
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>RPA-Gruppen</h4>
<div class="settings-sec-sub">Genaue <code>displayName</code>-Werte der Gruppen, die im RPA-Tab angezeigt werden. Eine pro Zeile.</div>
</div>
<div class="form-group">
<label class="lbl" for="setRpaGroups">RPA-Gruppennamen</label>
<textarea id="setRpaGroups" class="input mono" rows="4" spellcheck="false" placeholder="intune-userrole-windowsclientuser-rpa&#10;intune-userrole-windowsclientuser-rpa-fbt&#10;intune-userrole-windowsclientuser-rpa-pro"></textarea>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Benutzer-Suche</h4>
@@ -1131,7 +1146,7 @@
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Konfiguration pruefen</h4>
<div class="settings-sec-sub">Prueft die aktuell eingetragenen Werte gegen Microsoft Graph: Abteilungs-Gruppen-Lookup, RPA-Gruppen und Benutzer-Suche. Voraussetzung: Verbindung steht.</div>
<div class="settings-sec-sub">Prueft die aktuell eingetragenen Werte gegen Microsoft Graph: Abteilungs-Gruppen-Lookup und Benutzer-Suche. Voraussetzung: Verbindung steht.</div>
</div>
<div class="form-row">
<button type="button" class="btn btn-secondary" id="btnSettingsTest">Verbindung + Lookups testen</button>
+52
View File
@@ -5407,3 +5407,55 @@ body.read-only .app-row {
color: var(--text-dim, #888);
font-size: 13px;
}
/* =============================================================
Multi-Tenant: Topbar-Umschalter + Profil-Editor
============================================================= */
.tenant-switch {
height: 30px;
max-width: 200px;
padding: 0 8px;
border: 1px solid var(--hairline, var(--border));
border-radius: 6px;
background: var(--canvas, var(--bg2));
color: var(--ink, var(--text));
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
}
.tenant-switch:hover { border-color: var(--brand, var(--accent)); }
.tenant-list { display: flex; flex-direction: column; gap: 10px; }
.tenant-row {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
background: var(--bg2, transparent);
}
.tenant-row-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.tenant-row-head .tenant-label { flex: 1; }
.tenant-active {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text-dim, #888);
white-space: nowrap;
cursor: pointer;
}
.tenant-active input { cursor: pointer; }
.tenant-remove { flex: 0 0 auto; line-height: 1; padding: 4px 8px; }
.tenant-row-grid { display: flex; flex-direction: column; gap: 6px; }
.tenant-empty { padding: 8px 0; }
/* Multi-Tenant: Pro-Profil Overrides (Präfixe/RPA) */
.tenant-row-overrides { margin-top: 8px; display: flex; flex-direction: column; gap: 3px; }
.tenant-sub-lbl { font-size: 11px; font-weight: 600; color: var(--text-dim, #888); margin-top: 4px; }
.tenant-sub-hint { font-weight: 400; opacity: 0.8; }
.tenant-row-overrides textarea { resize: vertical; }
.tenant-naming-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }