add group export

This commit is contained in:
2026-06-17 08:14:10 +02:00
parent 2f271265e9
commit e98660134a
8 changed files with 558 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+87
View File
@@ -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/<id>/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
# ============================================================
@@ -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"
+188
View File
@@ -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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
document.getElementById('gexMembersArea').innerHTML = '<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>';
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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
return;
}
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px;"></div><span>Suche…</span></div>';
_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 = `<div class="gex-error">Suche fehlgeschlagen: ${escapeHtml(e.message)}</div>`;
}
}
function _gexRenderResults() {
const el = document.getElementById('gexSearchResults');
if (!el) return;
const items = GexState.searchResults;
if (items.length === 0) {
el.innerHTML = '<div class="gex-empty">Keine Gruppen gefunden.</div>';
return;
}
el.innerHTML = items.map(g => `
<div class="gex-group-item ${GexState.selectedGroup?.Id === g.Id ? 'selected' : ''}"
data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<div class="gex-group-name">${escapeHtml(g.DisplayName)}</div>
${g.Description ? `<div class="gex-group-desc">${escapeHtml(g.Description)}</div>` : ''}
</div>
`).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 =
'<div class="gex-loading"><div class="spinner" style="width:16px;height:16px;border-width:2px;"></div><span>Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)…</span></div>';
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 =
`<div class="gex-error">Fehler beim Laden: ${escapeHtml(e.message)}</div>`;
}
}
// 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 = '<div class="gex-empty">Keine Benutzer in dieser Gruppe gefunden.</div>';
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 = `<div class="gex-summary">${m.count} Benutzer`;
if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`;
html += '</div>';
for (const [path, users] of byPath.entries()) {
html += `<div class="gex-path-group">
<div class="gex-path-head">${escapeHtml(path)} <span class="count-badge">${users.length}</span></div>
<div class="gex-user-list">`;
for (const u of users) {
const upn = u.UserPrincipalName || u.Mail || u.Id;
const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`;
html += `<div class="gex-user-item" data-search="${escapeHtml(searchKey)}">
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-user-upn">${escapeHtml(upn)}</span>
</div>`;
}
html += '</div></div>';
}
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');
});
@@ -32,6 +32,9 @@
</div>
<div class="header-actions">
<button id="btnGroupExport" class="icon-btn icon-btn-header" title="Gruppen-Export" aria-label="Gruppen-Export öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><polyline points="17 8 19 10 23 6"/></svg>
</button>
<button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>
</button>
@@ -795,6 +798,52 @@
</div>
</div>
<!-- MODAL: Gruppen-Export -->
<div id="modalGroupExport" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-xl">
<div class="modal-head">
<h3>Gruppen-Export</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body gex-body">
<!-- Linke Spalte: Suche + Ergebnisliste -->
<div class="gex-left">
<div class="gex-section-title">Gruppe suchen</div>
<div class="search-wrap" style="margin-bottom:10px;">
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="search" id="gexSearch" class="input input-search" placeholder="Gruppenname eingeben…" autocomplete="off">
</div>
<div id="gexSearchResults" class="gex-results">
<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>
</div>
</div>
<!-- Rechte Spalte: aufgelöste Mitglieder -->
<div class="gex-right">
<div class="gex-section-title" id="gexMembersTitle">Mitglieder</div>
<div class="search-wrap" style="margin-bottom:10px;">
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="search" id="gexMemberFilter" class="input input-search" placeholder="Mitglieder filtern…" autocomplete="off">
</div>
<div id="gexMembersArea" class="gex-members-area">
<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>
</div>
</div>
</div>
<div class="modal-foot">
<span id="gexStats" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Schließen</button>
<button class="btn btn-primary" id="gexExportBtn" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Als CSV exportieren
</button>
</div>
</div>
</div>
<!-- Markdown-Renderer (bundled, MIT, marked v15) — wird im App-Detail-Panel
fuer die Beschreibung benutzt. Vor app.js laden damit window.marked
beim ersten render() bereitsteht. -->
@@ -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;