From c860ea9a10ddd6ba614c8e76ca3da31813bdc83a Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Wed, 17 Jun 2026 09:41:43 +0100 Subject: [PATCH] diverse anpassungen --- Intune/Intune-App-Manager-Web/README.md | 4 +- Intune/Intune-App-Manager-Web/Run.cmd | 2 +- Intune/Intune-App-Manager-Web/Start.ps1 | 12 +- Intune/Intune-App-Manager-Web/src/Api.ps1 | 95 ++++++++++- Intune/Intune-App-Manager-Web/www/app.js | 159 +++++++++++++++++- Intune/Intune-App-Manager-Web/www/help.md | 2 +- Intune/Intune-App-Manager-Web/www/index.html | 81 ++++++--- .../Intune-App-Manager-Web/www/members.html | 2 +- Intune/Intune-App-Manager-Web/www/styles.css | 113 ++++++++++++- 9 files changed, 429 insertions(+), 41 deletions(-) diff --git a/Intune/Intune-App-Manager-Web/README.md b/Intune/Intune-App-Manager-Web/README.md index 199d960..864cea2 100644 --- a/Intune/Intune-App-Manager-Web/README.md +++ b/Intune/Intune-App-Manager-Web/README.md @@ -1,6 +1,6 @@ -# Intune App-Manager – Web Edition +# Intune Manager – Web Edition -Modernes Web-Frontend fuer den Intune App-Zuweisungs-Manager. +Modernes Web-Frontend fuer den Intune Manager. PowerShell startet einen lokalen HTTP-Server, der das Frontend ausliefert und die Microsoft Graph-Aufrufe ausfuehrt. diff --git a/Intune/Intune-App-Manager-Web/Run.cmd b/Intune/Intune-App-Manager-Web/Run.cmd index a65eeca..3e90d21 100644 --- a/Intune/Intune-App-Manager-Web/Run.cmd +++ b/Intune/Intune-App-Manager-Web/Run.cmd @@ -16,4 +16,4 @@ REM Run.cmd REM Run.cmd -Port 8088 REM Run.cmd -NoBrowser -start "Intune App-Manager" /min powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%~dp0Start.ps1" %* +start "Intune Manager" /min powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%~dp0Start.ps1" %* diff --git a/Intune/Intune-App-Manager-Web/Start.ps1 b/Intune/Intune-App-Manager-Web/Start.ps1 index d0b200b..82390e8 100644 --- a/Intune/Intune-App-Manager-Web/Start.ps1 +++ b/Intune/Intune-App-Manager-Web/Start.ps1 @@ -1,4 +1,4 @@ -# Intune App-Zuweisungs-Manager - Web Edition +# Intune Manager - Web Edition # Startet einen lokalen HTTP-Server und oeffnet das moderne Web-Frontend. # # Aufruf: @@ -64,7 +64,7 @@ function Test-RequiredModules { $nuget = Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue if (-not $nuget -or $nuget.Version -lt [version]"2.8.5.201") { Write-Host "" - Write-Host "NuGet-PackageProvider fehlt — wird im User-Scope nachgezogen..." -ForegroundColor Yellow + Write-Host "NuGet-PackageProvider fehlt — wird im User-Scope nachgezogen..." -ForegroundColor Yellow try { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -ErrorAction Stop | Out-Null } catch { @@ -126,7 +126,7 @@ if (-not $SkipModuleCheck) { ) if (-not (Test-RequiredModules -Required $required -AutoInstall:$AutoInstall)) { Write-Host "" - Write-Host "Server-Start abgebrochen — benoetigte Module fehlen." -ForegroundColor Red + Write-Host "Server-Start abgebrochen — benoetigte Module fehlen." -ForegroundColor Red exit 1 } Write-Host "" @@ -144,7 +144,7 @@ $script:Settings = Read-Settings if ($TenantId) { $script:Settings.connection.tenantId = $TenantId } if ($ClientId) { $script:Settings.connection.clientId = $ClientId } -# Globale Konfiguration. TenantId/ClientId/Scopes spiegeln die Settings — +# Globale Konfiguration. TenantId/ClientId/Scopes spiegeln die Settings — # Connect-Endpoint und Co. lesen weiterhin $script:Config, das nach jedem # Settings-Save aktualisiert wird. $script:Config = @{ @@ -177,7 +177,7 @@ $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "" Write-Host "==============================================" -ForegroundColor Cyan -Write-Host " Intune App-Zuweisungs-Manager - Web Edition" -ForegroundColor Cyan +Write-Host " Intune Manager - Web Edition" -ForegroundColor Cyan Write-Host " Version: $script:ToolVersion" -ForegroundColor Green Write-Host " BUILD: $script:BuildStamp" -ForegroundColor DarkGray Write-Host "==============================================" -ForegroundColor Cyan @@ -185,7 +185,7 @@ Write-Host "" # ============================================================ # Alte Instanzen beenden: laeuft das Tool bereits, haelt der alte Prozess den -# Port (HttpListener via http.sys) — ein zweiter Start scheitert dann still und +# Port (HttpListener via http.sys) — ein zweiter Start scheitert dann still und # das alte Fenster bedient weiter mit altem Code. Darum alle anderen Start.ps1- # Prozesse DIESES Pfads vorher beenden, damit ein Neustart wirklich neu laedt. # ============================================================ diff --git a/Intune/Intune-App-Manager-Web/src/Api.ps1 b/Intune/Intune-App-Manager-Web/src/Api.ps1 index 6144c15..55aef9f 100644 --- a/Intune/Intune-App-Manager-Web/src/Api.ps1 +++ b/Intune/Intune-App-Manager-Web/src/Api.ps1 @@ -55,6 +55,14 @@ function Invoke-ApiHandler { return Get-GroupMembersEndpoint -GroupId $matches[1] } + if ($Method -eq "POST" -and $Path -match "^/api/groups/([^/]+)/members$") { + return Add-GroupMembersEndpoint -GroupId $matches[1] -Body $Body + } + + if ($Method -eq "DELETE" -and $Path -match "^/api/groups/([^/]+)/members/([^/]+)$") { + return Remove-GroupMemberEndpoint -GroupId $matches[1] -UserId $matches[2] + } + if ($Method -eq "GET" -and $Path -match "^/api/apps/([^/]+)/details$") { return Get-AppDetailsEndpoint -AppId $matches[1] } @@ -1191,6 +1199,91 @@ function Get-GroupMembersEndpoint { return @{ items = $items; count = $items.Count } } +function Add-GroupMembersEndpoint { + param([string]$GroupId, $Body) + $err = Test-Connected + if ($err) { return $err } + + if ([string]::IsNullOrWhiteSpace($GroupId)) { + return @{ __status = 400; error = "GroupId fehlt" } + } + if (-not $Body) { + return @{ __status = 400; error = "Request-Body fehlt" } + } + + $ids = @() + if ($Body.userIds) { + $ids = @($Body.userIds | ForEach-Object { [string]$_ } | Where-Object { $_ }) + } elseif ($Body.userId) { + $ids = @([string]$Body.userId) + } + if ($ids.Count -eq 0) { + return @{ __status = 400; error = "userId oder userIds fehlt im Body" } + } + + $success = 0; $errors = 0 + $results = @() + foreach ($uid in $ids) { + try { + Add-GraphMember -GroupId $GroupId -DirectoryObjectId $uid + $success++ + $results += @{ userId = $uid; status = "Success" } + Write-Host " [ADD MEMBER] GroupId=$GroupId UserId=$uid OK" -ForegroundColor Green + } catch { + $details = Get-GraphErrorFriendly -ErrorRecord $_ + if ($details.IsWarning) { + $success++ + $results += @{ userId = $uid; status = "AlreadyMember"; message = $details.Friendly } + Write-Host " [ADD MEMBER] $uid bereits Mitglied in $GroupId" -ForegroundColor DarkGreen + } else { + $errors++ + $results += @{ userId = $uid; status = "Error"; code = $details.Code; message = $details.Friendly } + Write-Host " [ADD MEMBER] FAIL $uid -> $GroupId [$($details.Code)] $($details.Friendly)" -ForegroundColor Red + } + } + } + + if ($script:State.GroupMembers.ContainsKey($GroupId)) { + $script:State.GroupMembers.Remove($GroupId) + } + + return @{ + ok = ($errors -eq 0) + success = [int]$success + errors = [int]$errors + total = [int]$ids.Count + results = $results + } +} + +function Remove-GroupMemberEndpoint { + param([string]$GroupId, [string]$UserId) + $err = Test-Connected + if ($err) { return $err } + + if ([string]::IsNullOrWhiteSpace($GroupId) -or [string]::IsNullOrWhiteSpace($UserId)) { + return @{ __status = 400; error = "GroupId und UserId erforderlich" } + } + + try { + $null = Invoke-MgGraphRequest ` + -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/$UserId/`$ref" ` + -Method DELETE + Write-Host " [REMOVE MEMBER] GroupId=$GroupId UserId=$UserId OK" -ForegroundColor Green + } catch { + $details = Get-GraphErrorFriendly -ErrorRecord $_ + $status = if ($details.Code -like '*404*') { 404 } elseif ($details.Code -like '*403*') { 403 } else { 500 } + Write-Host " [REMOVE MEMBER] FAIL [$($details.Code)] $($details.Friendly)" -ForegroundColor Red + return @{ __status = $status; error = $details.Friendly; code = $details.Code } + } + + if ($script:State.GroupMembers.ContainsKey($GroupId)) { + $script:State.GroupMembers.Remove($GroupId) + } + + return @{ ok = $true; groupId = $GroupId; userId = $UserId } +} + function Search-GroupsEndpoint { param($Query) $err = Test-Connected @@ -2689,7 +2782,7 @@ footer code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;bac diff --git a/Intune/Intune-App-Manager-Web/www/app.js b/Intune/Intune-App-Manager-Web/www/app.js index 2f5b0bd..dbb1662 100644 --- a/Intune/Intune-App-Manager-Web/www/app.js +++ b/Intune/Intune-App-Manager-Web/www/app.js @@ -1,5 +1,5 @@ // ============================================================= -// Intune App-Manager — Frontend +// Intune Manager — Frontend // Workflow: Connect → Select Targets → Pick Apps → Apply // ============================================================= @@ -4188,6 +4188,9 @@ const GexState = { selectedGroup: null, members: null, memberFilter: '', + activeTab: 'export', + userSearchResults: [], + selectedUsers: new Map(), }; function openGroupExportModal() { @@ -4196,6 +4199,9 @@ function openGroupExportModal() { GexState.selectedGroup = null; GexState.members = null; GexState.memberFilter = ''; + GexState.activeTab = 'export'; + GexState.userSearchResults = []; + GexState.selectedUsers = new Map(); document.getElementById('gexSearch').value = ''; document.getElementById('gexMemberFilter').value = ''; document.getElementById('gexSearchResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; @@ -4203,10 +4209,33 @@ function openGroupExportModal() { document.getElementById('gexMembersTitle').textContent = 'Mitglieder'; document.getElementById('gexStats').textContent = ''; document.getElementById('gexExportBtn').disabled = true; + // Add-Tab zurücksetzen + document.getElementById('gexUserSearch').value = ''; + document.getElementById('gexUserResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; + document.getElementById('gexSelectedUsers').innerHTML = '
Noch keine Benutzer ausgewählt.
'; + document.getElementById('gexSelectedCount').textContent = '0'; + document.getElementById('gexAddBtn').disabled = true; + // Tab-Anzeige auf Export zurücksetzen + _gexSwitchTab('export'); document.getElementById('modalGroupExport').classList.remove('hidden'); setTimeout(() => document.getElementById('gexSearch').focus(), 80); } +function _gexSwitchTab(tab) { + GexState.activeTab = tab; + document.querySelectorAll('.gex-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); + document.getElementById('gexPaneExport').classList.toggle('hidden', tab !== 'export'); + document.getElementById('gexPaneAdd').classList.toggle('hidden', tab !== 'add'); + document.getElementById('gexExportBtn').classList.toggle('hidden', tab !== 'export'); + document.getElementById('gexAddBtn').classList.toggle('hidden', tab !== 'add'); + document.getElementById('gexStats').textContent = ''; + if (tab === 'add') setTimeout(() => document.getElementById('gexUserSearch').focus(), 80); +} + +document.querySelectorAll('.gex-tab').forEach(btn => { + btn.addEventListener('click', () => _gexSwitchTab(btn.dataset.tab)); +}); + document.getElementById('btnGroupExport')?.addEventListener('click', openGroupExportModal); // Suche mit Debounce @@ -4257,8 +4286,12 @@ async function _gexSelectGroup(id, name) { GexState.selectedGroup = { Id: id, DisplayName: name }; GexState.members = null; GexState.memberFilter = ''; - document.getElementById('gexMemberFilter').value = ''; _gexRenderResults(); // aktualisiert selected-Highlight + _gexUpdateAddBtn(); + + if (GexState.activeTab !== 'export') return; + + document.getElementById('gexMemberFilter').value = ''; document.getElementById('gexMembersTitle').textContent = `Mitglieder — ${name}`; document.getElementById('gexStats').textContent = ''; document.getElementById('gexExportBtn').disabled = true; @@ -4340,6 +4373,128 @@ function _gexRenderMembers() { if (GexState.memberFilter) _gexApplyFilter(); } +// ---- Benutzer-Suche im Add-Tab ---- + +let _gexUserSearchTimer = null; +document.getElementById('gexUserSearch')?.addEventListener('input', e => { + clearTimeout(_gexUserSearchTimer); + const q = e.target.value.trim(); + if (q.length < 2) { + GexState.userSearchResults = []; + document.getElementById('gexUserResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; + return; + } + document.getElementById('gexUserResults').innerHTML = '
Suche…
'; + _gexUserSearchTimer = setTimeout(() => _gexDoUserSearch(q), 350); +}); + +async function _gexDoUserSearch(q) { + try { + const data = await api(`/api/users?q=${encodeURIComponent(q)}`); + GexState.userSearchResults = data.items || []; + _gexRenderUserResults(); + } catch (e) { + document.getElementById('gexUserResults').innerHTML = `
Suche fehlgeschlagen: ${escapeHtml(e.message)}
`; + } +} + +function _gexRenderUserResults() { + const el = document.getElementById('gexUserResults'); + if (!el) return; + const items = GexState.userSearchResults; + if (items.length === 0) { + el.innerHTML = '
Keine Benutzer gefunden.
'; + return; + } + el.innerHTML = items.map(u => { + const already = GexState.selectedUsers.has(u.Id); + return `
+ ${escapeHtml(u.DisplayName || '—')} + ${escapeHtml(u.UserPrincipalName || u.Mail || '')} + ${already ? '✓' : ''} +
`; + }).join(''); + el.querySelectorAll('.gex-user-result-item:not(.selected)').forEach(item => { + item.addEventListener('click', () => _gexSelectUser({ Id: item.dataset.id, DisplayName: item.dataset.name, UserPrincipalName: item.dataset.upn })); + }); +} + +function _gexSelectUser(u) { + if (GexState.selectedUsers.has(u.Id)) return; + GexState.selectedUsers.set(u.Id, u); + _gexRenderUserResults(); + _gexRenderSelectedUsers(); + _gexUpdateAddBtn(); +} + +function _gexDeselectUser(id) { + GexState.selectedUsers.delete(id); + _gexRenderUserResults(); + _gexRenderSelectedUsers(); + _gexUpdateAddBtn(); +} + +function _gexRenderSelectedUsers() { + const el = document.getElementById('gexSelectedUsers'); + const countEl = document.getElementById('gexSelectedCount'); + if (!el) return; + const users = [...GexState.selectedUsers.values()]; + countEl.textContent = users.length; + if (users.length === 0) { + el.innerHTML = '
Noch keine Benutzer ausgewählt.
'; + return; + } + el.innerHTML = users.map(u => ` +
+ ${escapeHtml(u.DisplayName || '—')} + ${escapeHtml(u.UserPrincipalName || '')} + +
+ `).join(''); + el.querySelectorAll('.gex-selected-chip-remove').forEach(btn => { + btn.addEventListener('click', () => _gexDeselectUser(btn.dataset.id)); + }); +} + +function _gexUpdateAddBtn() { + const btn = document.getElementById('gexAddBtn'); + if (!btn) return; + btn.disabled = !(GexState.selectedGroup && GexState.selectedUsers.size > 0); +} + +document.getElementById('gexAddBtn')?.addEventListener('click', async () => { + if (!GexState.selectedGroup) { toast('Bitte zuerst eine Gruppe auswählen.', 'warn'); return; } + if (GexState.selectedUsers.size === 0) { toast('Bitte mindestens einen Benutzer auswählen.', 'warn'); return; } + + const btn = document.getElementById('gexAddBtn'); + btn.disabled = true; + const origHtml = btn.innerHTML; + btn.innerHTML = '
Wird hinzugefügt…'; + + const userIds = [...GexState.selectedUsers.keys()]; + try { + const data = await api(`/api/groups/${encodeURIComponent(GexState.selectedGroup.Id)}/members`, { + method: 'POST', + body: { userIds }, + }); + if (data.errors > 0) { + toast(`${data.success} von ${data.total} hinzugefügt, ${data.errors} Fehler.`, 'warn'); + } else { + toast(`${data.success} Benutzer zu „${GexState.selectedGroup.DisplayName}" hinzugefügt.`, 'ok'); + GexState.selectedUsers.clear(); + _gexRenderSelectedUsers(); + document.getElementById('gexUserSearch').value = ''; + document.getElementById('gexUserResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; + } + } catch (e) { + toast(`Fehler: ${e.message}`, 'err'); + } finally { + btn.innerHTML = origHtml; + _gexUpdateAddBtn(); + } +}); + document.getElementById('gexExportBtn')?.addEventListener('click', () => { if (!GexState.members?.users) return; const m = GexState.members; diff --git a/Intune/Intune-App-Manager-Web/www/help.md b/Intune/Intune-App-Manager-Web/www/help.md index d5b20ae..2768f01 100644 --- a/Intune/Intune-App-Manager-Web/www/help.md +++ b/Intune/Intune-App-Manager-Web/www/help.md @@ -1,4 +1,4 @@ -# Intune App-Manager — Hilfe +# Intune Manager — Hilfe Web-Frontend für Microsoft Intune. Lädt Apps und Gruppen aus deinem Tenant über Microsoft Graph und macht das Verwalten von Zuweisungen schneller als im Intune-Portal selbst. diff --git a/Intune/Intune-App-Manager-Web/www/index.html b/Intune/Intune-App-Manager-Web/www/index.html index df997b0..d58c847 100644 --- a/Intune/Intune-App-Manager-Web/www/index.html +++ b/Intune/Intune-App-Manager-Web/www/index.html @@ -3,7 +3,7 @@ - Intune App-Manager + Intune Manager