diff --git a/.DS_Store b/.DS_Store index dd36122..bda96e1 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Intune/.DS_Store b/Intune/.DS_Store new file mode 100644 index 0000000..d295c24 Binary files /dev/null and b/Intune/.DS_Store differ diff --git a/Intune/Intune-App-Manager-Web/.DS_Store b/Intune/Intune-App-Manager-Web/.DS_Store new file mode 100644 index 0000000..ef180b2 Binary files /dev/null and b/Intune/Intune-App-Manager-Web/.DS_Store differ diff --git a/Intune/Intune-App-Manager-Web/src/Api.ps1 b/Intune/Intune-App-Manager-Web/src/Api.ps1 index 6f9f381..6144c15 100644 --- a/Intune/Intune-App-Manager-Web/src/Api.ps1 +++ b/Intune/Intune-App-Manager-Web/src/Api.ps1 @@ -29,6 +29,7 @@ function Invoke-ApiHandler { "GET /api/groups" { return Get-GroupsEndpoint -Query $Query } "GET /api/groups/rpa" { return Get-RpaGroupsEndpoint } + "GET /api/groups/search" { return Search-GroupsEndpoint -Query $Query } "POST /api/groups" { return New-GroupEndpoint -Body $Body } "POST /api/groups/check" { return Test-GroupNameEndpoint -Body $Body } @@ -45,6 +46,11 @@ function Invoke-ApiHandler { } # 2-segment fallbacks (z.B. /api/groups//members) + # Export-Route VOR der generischen /members-Route pruefen + if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") { + return Get-GroupMembersExportEndpoint -GroupId $matches[1] + } + if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") { return Get-GroupMembersEndpoint -GroupId $matches[1] } @@ -1185,6 +1191,87 @@ function Get-GroupMembersEndpoint { return @{ items = $items; count = $items.Count } } +function Search-GroupsEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + + $term = [string]$Query.q + if ([string]::IsNullOrWhiteSpace($term) -or $term.Length -lt 2) { + return @{ items = @(); count = 0 } + } + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $raw = @(Search-GraphGroups -SearchTerm $term -Top 50) + $sw.Stop() + Write-Host " [GSEARCH] '$term': $($raw.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray + + $items = @() + foreach ($g in $raw) { + $id = if ($g.id) { $g.id } else { $g.Id } + $name = if ($g.displayName) { $g.displayName } else { $g.displayname } + if ($id) { + $items += [pscustomobject]@{ + Id = [string]$id + DisplayName = [string]$name + Description = [string]$g.description + } + } + } + return @{ items = $items; count = $items.Count } +} + +function Get-GroupMembersExportEndpoint { + # Loest alle Benutzer einer Gruppe auf, einschliesslich Mitglieder aus + # verschachtelten Unter-Gruppen. Gibt die flache, deduplizierte Benutzer- + # liste mit SourcePath-Angabe zurueck — das Frontend erzeugt daraus den CSV. + param([string]$GroupId) + $err = Test-Connected + if ($err) { return $err } + + if ([string]::IsNullOrWhiteSpace($GroupId)) { + return @{ __status = 400; error = "GroupId fehlt" } + } + + $groupName = $GroupId + try { + $g = Get-GraphGroupById -Id $GroupId -Property @("id","displayName") + if ($g.displayName) { $groupName = [string]$g.displayName } + } catch { + return @{ __status = 404; error = "Gruppe nicht gefunden: $($_.Exception.Message)" } + } + + Write-Host "[EXPORT] Starte Aufloesung: '$groupName' ($GroupId)" -ForegroundColor Cyan + $sw = [System.Diagnostics.Stopwatch]::StartNew() + + $allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName) + + # Deduplizieren: erster Treffer (= direktes Mitglied) gewinnt + $seen = @{} + $unique = [System.Collections.Generic.List[object]]::new() + $dups = 0 + foreach ($u in $allUsers) { + if (-not $seen.ContainsKey($u.Id)) { + $seen[$u.Id] = $true + $unique.Add($u) + } else { + $dups++ + } + } + + $sw.Stop() + Write-Host " -> $($unique.Count) eindeutige Benutzer, $dups Duplikate, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green + + return @{ + groupId = $GroupId + groupName = $groupName + users = $unique.ToArray() + count = $unique.Count + duplicates = $dups + resolvedMs = [int]$sw.ElapsedMilliseconds + } +} + # ============================================================ # Users # ============================================================ diff --git a/Intune/Intune-App-Manager-Web/src/Graph.ps1 b/Intune/Intune-App-Manager-Web/src/Graph.ps1 index e72d293..d8b7791 100644 --- a/Intune/Intune-App-Manager-Web/src/Graph.ps1 +++ b/Intune/Intune-App-Manager-Web/src/Graph.ps1 @@ -101,6 +101,83 @@ function Get-GraphGroupMembers { return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName" } +function Search-GraphGroups { + # Sucht Gruppen nach displayName. Versucht zuerst startswith (schnell), + # dann $search-Fallback (Substring, braucht ConsistencyLevel: eventual). + param([string]$SearchTerm, [int]$Top = 50) + $term = $SearchTerm -replace "'", "''" + try { + $filter = "startswith(displayName,'$term')" + $uri = "https://graph.microsoft.com/v1.0/groups?`$select=id,displayName,description&`$filter=$([uri]::EscapeDataString($filter))&`$top=$Top" + $resp = Invoke-MgGraphRequest -Uri $uri -Method GET + $items = @() + if ($resp -and $resp.value) { $items = @($resp.value) } + if ($items.Count -gt 0) { return $items } + } catch { + Write-Host " [GSEARCH] startswith fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow + } + try { + $clean = $SearchTerm -replace '"', '' + $searchExpr = [uri]::EscapeDataString('"displayName:' + $clean + '"') + $uri = "https://graph.microsoft.com/v1.0/groups?`$select=id,displayName,description&`$search=$searchExpr&`$top=$Top" + $resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" } + if ($resp -and $resp.value) { return @($resp.value) } + } catch { + Write-Host " [GSEARCH] search-Fallback fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow + } + return @() +} + +function Resolve-GroupMembersWithNesting { + # Loest alle Benutzer einer Gruppe rekursiv auf (inkl. verschachtelter Unter- + # gruppen). Gibt eine flache Liste zurueck; jeder Eintrag traegt den Pfad + # der Herkunfts-Gruppen (z.B. "Top-Gruppe > Unter-Gruppe"). + # Zirkulaere Referenzen werden ueber ein HashSet vermieden. + param( + [Parameter(Mandatory=$true)][string]$GroupId, + [string]$GroupName = "", + [System.Collections.Generic.HashSet[string]]$Visited = $null, + [string[]]$Path = @() + ) + if ($null -eq $Visited) { + $Visited = New-Object System.Collections.Generic.HashSet[string] + } + if (-not $Visited.Add($GroupId)) { return @() } + + $thisPath = if ($GroupName) { @($Path) + @($GroupName) } else { $Path } + $sourcePath = $thisPath -join " > " + + $raw = @() + try { + $raw = @(Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName,mail") + } catch { + Write-Host " [EXPORT] Mitglieder von $GroupId konnten nicht geladen werden: $($_.Exception.Message)" -ForegroundColor DarkYellow + return @() + } + + $result = [System.Collections.Generic.List[object]]::new() + foreach ($m in $raw) { + $type = [string]$m.'@odata.type' + if ($type -like '*#microsoft.graph.group*') { + $sub = Resolve-GroupMembersWithNesting ` + -GroupId ([string]$m.id) ` + -GroupName ([string]$m.displayName) ` + -Visited $Visited ` + -Path $thisPath + foreach ($u in $sub) { $result.Add($u) } + } else { + $result.Add([pscustomobject]@{ + Id = [string]$m.id + DisplayName = [string]$m.displayName + UserPrincipalName = [string]$m.userPrincipalName + Mail = [string]$m.mail + SourcePath = $sourcePath + }) + } + } + return $result.ToArray() +} + function Get-GraphGroupMembersTransitive { param([string]$GroupId) return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName" diff --git a/Intune/Intune-App-Manager-Web/www/app.js b/Intune/Intune-App-Manager-Web/www/app.js index edbf508..93f2f0a 100644 --- a/Intune/Intune-App-Manager-Web/www/app.js +++ b/Intune/Intune-App-Manager-Web/www/app.js @@ -4166,3 +4166,191 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => { setTimeout(() => openSettingsModal(), 200); } })(); + +// ============================================================= +// Gruppen-Export +// ============================================================= + +const GexState = { + searchResults: [], + selectedGroup: null, + members: null, + memberFilter: '', +}; + +function openGroupExportModal() { + if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } + GexState.searchResults = []; + GexState.selectedGroup = null; + GexState.members = null; + GexState.memberFilter = ''; + document.getElementById('gexSearch').value = ''; + document.getElementById('gexMemberFilter').value = ''; + document.getElementById('gexSearchResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; + document.getElementById('gexMembersArea').innerHTML = '
Gruppe auswählen um Mitglieder zu laden.
'; + document.getElementById('gexMembersTitle').textContent = 'Mitglieder'; + document.getElementById('gexStats').textContent = ''; + document.getElementById('gexExportBtn').disabled = true; + document.getElementById('modalGroupExport').classList.remove('hidden'); + setTimeout(() => document.getElementById('gexSearch').focus(), 80); +} + +document.getElementById('btnGroupExport')?.addEventListener('click', openGroupExportModal); + +// Suche mit Debounce +let _gexSearchTimer = null; +document.getElementById('gexSearch')?.addEventListener('input', e => { + clearTimeout(_gexSearchTimer); + const q = e.target.value.trim(); + if (q.length < 2) { + GexState.searchResults = []; + document.getElementById('gexSearchResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; + return; + } + document.getElementById('gexSearchResults').innerHTML = '
Suche…
'; + _gexSearchTimer = setTimeout(() => _gexDoSearch(q), 350); +}); + +async function _gexDoSearch(q) { + try { + const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); + GexState.searchResults = data.items || []; + _gexRenderResults(); + } catch (e) { + document.getElementById('gexSearchResults').innerHTML = `
Suche fehlgeschlagen: ${escapeHtml(e.message)}
`; + } +} + +function _gexRenderResults() { + const el = document.getElementById('gexSearchResults'); + if (!el) return; + const items = GexState.searchResults; + if (items.length === 0) { + el.innerHTML = '
Keine Gruppen gefunden.
'; + return; + } + el.innerHTML = items.map(g => ` +
+
${escapeHtml(g.DisplayName)}
+ ${g.Description ? `
${escapeHtml(g.Description)}
` : ''} +
+ `).join(''); + el.querySelectorAll('.gex-group-item').forEach(item => { + item.addEventListener('click', () => _gexSelectGroup(item.dataset.id, item.dataset.name)); + }); +} + +async function _gexSelectGroup(id, name) { + GexState.selectedGroup = { Id: id, DisplayName: name }; + GexState.members = null; + GexState.memberFilter = ''; + document.getElementById('gexMemberFilter').value = ''; + _gexRenderResults(); // aktualisiert selected-Highlight + document.getElementById('gexMembersTitle').textContent = `Mitglieder — ${name}`; + document.getElementById('gexStats').textContent = ''; + document.getElementById('gexExportBtn').disabled = true; + document.getElementById('gexMembersArea').innerHTML = + '
Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)…
'; + + try { + const data = await api(`/api/groups/${encodeURIComponent(id)}/members/export`); + GexState.members = data; + _gexRenderMembers(); + document.getElementById('gexExportBtn').disabled = data.count === 0; + let stats = `${data.count} Benutzer`; + if (data.duplicates > 0) stats += ` · ${data.duplicates} Duplikate aus Verschachtelung entfernt`; + stats += ` · ${data.resolvedMs}ms`; + document.getElementById('gexStats').textContent = stats; + } catch (e) { + document.getElementById('gexMembersArea').innerHTML = + `
Fehler beim Laden: ${escapeHtml(e.message)}
`; + } +} + +// Live-Filter für die Mitgliederliste +document.getElementById('gexMemberFilter')?.addEventListener('input', e => { + GexState.memberFilter = e.target.value.trim().toLowerCase(); + _gexApplyFilter(); +}); + +function _gexApplyFilter() { + const q = GexState.memberFilter; + document.querySelectorAll('#gexMembersArea .gex-user-item').forEach(row => { + const text = row.dataset.search || ''; + row.classList.toggle('hidden-filter', q !== '' && !text.includes(q)); + }); + // Pfad-Gruppen ohne sichtbare Zeilen ausblenden + document.querySelectorAll('#gexMembersArea .gex-path-group').forEach(pg => { + const visible = pg.querySelectorAll('.gex-user-item:not(.hidden-filter)').length > 0; + pg.style.display = visible ? '' : 'none'; + }); +} + +function _gexRenderMembers() { + const area = document.getElementById('gexMembersArea'); + if (!area || !GexState.members) return; + const m = GexState.members; + + if (m.count === 0) { + area.innerHTML = '
Keine Benutzer in dieser Gruppe gefunden.
'; + return; + } + + // Nach SourcePath gruppieren + const byPath = new Map(); + for (const u of m.users) { + const p = u.SourcePath || m.groupName; + if (!byPath.has(p)) byPath.set(p, []); + byPath.get(p).push(u); + } + + let html = `
${m.count} Benutzer`; + if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`; + html += '
'; + + for (const [path, users] of byPath.entries()) { + html += `
+
${escapeHtml(path)} ${users.length}
+
`; + for (const u of users) { + const upn = u.UserPrincipalName || u.Mail || u.Id; + const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`; + html += `
+ ${escapeHtml(u.DisplayName || '—')} + ${escapeHtml(upn)} +
`; + } + html += '
'; + } + + area.innerHTML = html; + if (GexState.memberFilter) _gexApplyFilter(); +} + +document.getElementById('gexExportBtn')?.addEventListener('click', () => { + if (!GexState.members?.users) return; + const m = GexState.members; + const rows = [['DisplayName', 'UserPrincipalName', 'Mail', 'SourcePath', 'GroupId', 'GroupName']]; + for (const u of m.users) { + rows.push([ + u.DisplayName || '', + u.UserPrincipalName || '', + u.Mail || '', + u.SourcePath || m.groupName, + m.groupId, + m.groupName, + ]); + } + const csv = rows.map(r => + r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',') + ).join('\r\n'); + const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${m.groupName.replace(/[^a-zA-Z0-9_-]/g, '_')}-members.csv`; + a.click(); + URL.revokeObjectURL(url); + toast(`${m.count} Benutzer exportiert.`, 'ok'); +}); diff --git a/Intune/Intune-App-Manager-Web/www/index.html b/Intune/Intune-App-Manager-Web/www/index.html index 5286a81..df997b0 100644 --- a/Intune/Intune-App-Manager-Web/www/index.html +++ b/Intune/Intune-App-Manager-Web/www/index.html @@ -32,6 +32,9 @@
+ @@ -795,6 +798,52 @@
+ + + diff --git a/Intune/Intune-App-Manager-Web/www/styles.css b/Intune/Intune-App-Manager-Web/www/styles.css index 14c4c2c..bfef6f9 100644 --- a/Intune/Intune-App-Manager-Web/www/styles.css +++ b/Intune/Intune-App-Manager-Web/www/styles.css @@ -4392,6 +4392,163 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown { border-color: var(--hairline); } +/* ============================================================= + Gruppen-Export Modal + ============================================================= */ + +.gex-body { + display: flex; + gap: 16px; + min-height: 420px; + padding: 0 !important; + overflow: hidden; +} + +.gex-left { + width: 300px; + flex-shrink: 0; + border-right: 1px solid var(--hairline); + padding: 16px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.gex-right { + flex: 1; + padding: 16px; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.gex-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--muted); + margin-bottom: 8px; +} + +.gex-results { + flex: 1; + overflow-y: auto; + border: 1px solid var(--hairline); + border-radius: var(--r-md); +} + +.gex-group-item { + padding: 9px 12px; + border-bottom: 1px solid var(--hairline-soft); + cursor: pointer; + transition: background var(--tx); +} +.gex-group-item:last-child { border-bottom: none; } +.gex-group-item:hover { background: var(--surface-soft); } +.gex-group-item.selected { + background: var(--surface-soft); + border-left: 3px solid var(--ink); + padding-left: 9px; +} + +.gex-group-name { + font-size: 13px; + font-weight: 500; + color: var(--text); +} +.gex-group-desc { + font-size: 11.5px; + color: var(--muted); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gex-members-area { + flex: 1; + overflow-y: auto; + border: 1px solid var(--hairline); + border-radius: var(--r-md); +} + +.gex-summary { + padding: 8px 12px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--hairline-soft); + background: var(--surface-soft); +} + +.gex-path-group { + border-bottom: 1px solid var(--hairline-soft); +} +.gex-path-group:last-child { border-bottom: none; } + +.gex-path-head { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + background: var(--surface-soft); + border-bottom: 1px solid var(--hairline-soft); + color: var(--muted); + position: sticky; + top: 0; + z-index: 1; +} + +.gex-user-list { } + +.gex-user-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--hairline-soft); + font-size: 13px; +} +.gex-user-item:last-child { border-bottom: none; } +.gex-user-item.hidden-filter { display: none; } + +.gex-user-name { + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 120px; + max-width: 220px; +} +.gex-user-upn { + font-size: 11.5px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gex-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 16px; + font-size: 13px; + color: var(--muted); +} + +.gex-hint, .gex-empty, .gex-error { + padding: 16px; + font-size: 13px; + color: var(--muted); + text-align: center; +} +.gex-error { color: var(--error); } + /* Kompakte Optik fuer beide Themes — passt besser zum Tool */ .flatpickr-calendar { font-family: inherit;