Group Management: Benutzer-Tab mit Gruppen-Mitgliedschaften

Neuer Tab "Benutzer" im Group Management: Benutzer suchen und alle
Gruppen anzeigen, in denen dieser Mitglied ist (transitiveMemberOf).
Gruppen filterbar, mit Typ-Badge (Sicherheitsgruppe/M365/Verteiler).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 10:03:49 +02:00
co-authored by Claude Sonnet 4.6
parent 2f0af6e577
commit ec130031cb
4 changed files with 179 additions and 1 deletions
+30
View File
@@ -66,6 +66,10 @@ function Invoke-ApiHandler {
return Remove-GroupMemberEndpoint -GroupId $matches[1] -UserId $matches[2]
}
if ($Method -eq "GET" -and $Path -match "^/api/users/([^/]+)/memberof$") {
return Get-UserMemberOfEndpoint -UserId $matches[1]
}
if ($Method -eq "GET" -and $Path -match "^/api/apps/([^/]+)/details$") {
return Get-AppDetailsEndpoint -AppId $matches[1]
}
@@ -1490,6 +1494,32 @@ function Search-UsersEndpoint {
return @{ items = $items; count = $items.Count }
}
function Get-UserMemberOfEndpoint {
param($UserId)
$err = Test-Connected
if ($err) { return $err }
$groups = @()
$uri = "https://graph.microsoft.com/v1.0/users/$UserId/transitiveMemberOf/microsoft.graph.group?`$select=id,displayName,description,groupTypes,mailEnabled,securityEnabled&`$top=100"
while ($uri) {
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET
foreach ($g in $resp.value) {
$type = 'Sicherheitsgruppe'
if ($g.groupTypes -contains 'Unified') { $type = 'Microsoft 365' }
elseif ($g.mailEnabled -and -not $g.securityEnabled) { $type = 'Verteiler' }
$groups += [pscustomobject]@{
Id = $g.id
DisplayName = $g.displayName
Description = $g.description
Type = $type
}
}
$uri = $resp.'@odata.nextLink'
}
$groups = $groups | Sort-Object DisplayName
return @{ groups = $groups; count = $groups.Count }
}
# ============================================================
# Apps
# ============================================================
+103
View File
@@ -4372,10 +4372,13 @@ function _gexSwitchTab(tab) {
document.getElementById('gexPaneExport').classList.toggle('hidden', tab !== 'export');
document.getElementById('gexPaneAdd').classList.toggle('hidden', tab !== 'add');
document.getElementById('gexPaneImport').classList.toggle('hidden', tab !== 'import');
document.getElementById('gexPaneUser').classList.toggle('hidden', tab !== 'user');
document.getElementById('gexLeft').classList.toggle('hidden', tab === 'user');
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);
if (tab === 'user') setTimeout(() => document.getElementById('gexUmSearch').focus(), 80);
}
document.querySelectorAll('.gex-tab').forEach(btn => {
@@ -4603,6 +4606,106 @@ document.getElementById('gexRemoveBtn')?.addEventListener('click', async () => {
}
});
// ---- Benutzer → Gruppen (User-Tab) ----
(function () {
let umDebounce = null;
let umSelected = null; // { Id, DisplayName, UserPrincipalName }
const searchInput = document.getElementById('gexUmSearch');
const resultsDiv = document.getElementById('gexUmResults');
const groupsList = document.getElementById('gexUmGroupsList');
const groupsTitle = document.getElementById('gexUmGroupsTitle');
const groupCount = document.getElementById('gexUmGroupCount');
const filterWrap = document.getElementById('gexUmFilterWrap');
const filterInput = document.getElementById('gexUmFilter');
searchInput.addEventListener('input', () => {
clearTimeout(umDebounce);
const q = searchInput.value.trim();
if (q.length < 2) {
resultsDiv.innerHTML = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
return;
}
umDebounce = setTimeout(() => _umSearchUser(q), 320);
});
async function _umSearchUser(q) {
resultsDiv.innerHTML = '<div class="gex-hint">Suche…</div>';
try {
const data = await api(`/api/users?q=${encodeURIComponent(q)}`);
if (!data.items || data.items.length === 0) {
resultsDiv.innerHTML = '<div class="gex-hint">Keine Benutzer gefunden.</div>';
return;
}
let html = '';
for (const u of data.items) {
const upn = u.UserPrincipalName || u.Mail || u.Id;
html += `<div class="gex-result-item" data-uid="${escapeHtml(u.Id)}"
data-name="${escapeHtml(u.DisplayName || upn)}"
data-upn="${escapeHtml(upn)}">
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-user-upn">${escapeHtml(upn)}</span>
</div>`;
}
resultsDiv.innerHTML = html;
resultsDiv.querySelectorAll('.gex-result-item').forEach(el => {
el.addEventListener('click', () => _umSelectUser({
Id: el.dataset.uid,
DisplayName: el.dataset.name,
UserPrincipalName: el.dataset.upn
}));
});
} catch (e) {
resultsDiv.innerHTML = `<div class="gex-hint error">Fehler: ${escapeHtml(String(e))}</div>`;
}
}
async function _umSelectUser(user) {
umSelected = user;
searchInput.value = user.DisplayName || user.UserPrincipalName;
resultsDiv.innerHTML = '';
groupsList.innerHTML = '<div class="gex-hint">Lade Gruppen…</div>';
groupsTitle.style.display = '';
filterWrap.style.display = '';
filterInput.value = '';
groupCount.textContent = '…';
try {
const data = await api(`/api/users/${encodeURIComponent(user.Id)}/memberof`);
groupCount.textContent = data.count;
if (!data.groups || data.groups.length === 0) {
groupsList.innerHTML = '<div class="gex-hint">Keine Gruppen gefunden.</div>';
filterWrap.style.display = 'none';
return;
}
_umRenderGroups(data.groups);
} catch (e) {
groupsList.innerHTML = `<div class="gex-hint error">Fehler: ${escapeHtml(String(e))}</div>`;
}
}
function _umRenderGroups(groups) {
let html = '';
for (const g of groups) {
const desc = g.Description ? `<span class="gex-user-upn">${escapeHtml(g.Description)}</span>` : '';
const badge = `<span class="gex-type-badge">${escapeHtml(g.Type)}</span>`;
html += `<div class="gex-group-row" data-search="${escapeHtml((g.DisplayName || '').toLowerCase())} ${escapeHtml((g.Description || '').toLowerCase())}">
<span class="gex-user-name">${escapeHtml(g.DisplayName || '—')}</span>
${desc}
${badge}
</div>`;
}
groupsList.innerHTML = html;
}
filterInput.addEventListener('input', () => {
const q = filterInput.value.toLowerCase();
groupsList.querySelectorAll('.gex-group-row').forEach(row => {
row.classList.toggle('hidden-filter', q.length > 0 && !row.dataset.search.includes(q));
});
});
})();
// ---- Benutzer-Suche im Add-Tab ----
let _gexUserSearchTimer = null;
+22 -1
View File
@@ -291,12 +291,13 @@
<button class="gex-tab active" data-tab="export">Mitglieder &amp; Export</button>
<button class="gex-tab gex-add-pane" data-tab="add">Benutzer hinzufügen</button>
<button class="gex-tab gex-add-pane" data-tab="import">CSV-Import</button>
<button class="gex-tab" data-tab="user">Benutzer</button>
</div>
<div class="gex-columns group-mgmt-columns">
<!-- Linke Spalte: Gruppensuche -->
<div class="gex-left">
<div class="gex-left" id="gexLeft">
<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>
@@ -399,6 +400,26 @@
</div>
</div>
<!-- Benutzer-Tab: User → Gruppen -->
<div class="gex-right hidden" id="gexPaneUser">
<div class="gex-section-title">Benutzer 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="gexUmSearch" class="input input-search" placeholder="Name, UPN oder E-Mail…" autocomplete="off">
</div>
<div id="gexUmResults" class="gex-results" style="max-height:160px;overflow-y:auto;margin-bottom:12px;">
<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>
</div>
<div class="gex-section-title" id="gexUmGroupsTitle" style="display:none;">
Mitglied in <span id="gexUmGroupCount" class="count-badge">0</span> Gruppen
</div>
<div class="search-wrap" id="gexUmFilterWrap" style="display:none;margin-bottom:8px;">
<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="gexUmFilter" class="input input-search" placeholder="Gruppen filtern…" autocomplete="off">
</div>
<div id="gexUmGroupsList" class="gex-members-area" style="flex:1;overflow-y:auto;"></div>
</div>
</div>
</div>
</section>
+24
View File
@@ -4714,6 +4714,30 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown {
text-overflow: ellipsis;
}
.gex-group-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--hairline-soft);
font-size: 13px;
}
.gex-group-row:last-child { border-bottom: none; }
.gex-group-row.hidden-filter { display: none; }
.gex-group-row .gex-user-name { min-width: 140px; max-width: none; flex: 1; }
.gex-group-row .gex-user-upn { flex: 1; }
.gex-type-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
background: var(--hairline-soft);
color: var(--muted);
}
.gex-loading {
display: flex;
align-items: center;