v0.1.24 — App Report, CSV Import, Read-Only Mode, Group Management

- App Report: Installationszähler pro App via Graph $batch (deviceStatuses)
- Geräte-Export: CSV mit Maschinennamen pro App (GET /api/apps/devicestatus)
- CSV-Import: Massenimport von Benutzern in Gruppen mit Header-Erkennung
- Read-Only-Modus: Schreib-Tabs/Buttons automatisch ausblenden
- Ergebnis-Banner nach CSV-Import (hinzugefügt/bereits Mitglied/Fehler/nicht gefunden)
- Dokumentation aktualisiert (README, help.md, CHANGELOG)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 07:12:49 +02:00
co-authored by Claude Sonnet 4.6
commit 34811ff583
24 changed files with 17052 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gruppen-Mitglieder · Intune Manager</title>
<script>
(function () {
try {
var saved = localStorage.getItem('iam-theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/styles.css">
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='22' fill='%23111111'/%3E%3Ctext x='50' y='70' font-size='62' text-anchor='middle' fill='white' font-family='Inter,Arial' font-weight='600'%3EI%3C/text%3E%3C/svg%3E">
</head>
<body class="members-page">
<header class="topbar">
<div class="brand">
<div class="logo">I</div>
<div class="brand-text">
<div class="brand-name">Gruppen-Mitglieder</div>
</div>
</div>
<div class="header-actions">
<a id="entraLink" class="btn btn-secondary btn-sm" href="#" target="_blank" rel="noopener" title="In Entra oeffnen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
In Entra
</a>
</div>
</header>
<main class="members-main">
<section class="members-card">
<header class="members-head">
<h1 id="grpName" class="members-title">Mitglieder werden geladen...</h1>
<div class="members-meta">
<code id="grpId" class="meta-mono"></code>
<span id="grpCount" class="count-badge accent">0</span>
</div>
</header>
<div class="members-toolbar">
<div class="search-wrap flex-1">
<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="mFilter" placeholder="Mitglieder filtern (Name, UPN, E-Mail)..." class="input input-search" autocomplete="off">
</div>
<button id="btnReload" class="icon-btn" title="Neu laden" aria-label="Neu laden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
</button>
</div>
<div id="mList" class="members-list"></div>
</section>
</main>
<div id="toasts" class="toasts"></div>
<script>
const params = new URLSearchParams(location.search);
const groupId = params.get('id') || '';
const groupName = params.get('name') || '';
const ENTRA_GROUP_URL = id => `https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Overview/groupId/${encodeURIComponent(id)}/menuId/`;
const ENTRA_USER_URL = id => `https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${encodeURIComponent(id)}`;
const ENTRA_ICON_SVG = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
function esc(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function setTitle(name, count) {
document.title = `${name || groupId} (${count}) · Mitglieder`;
}
const state = { members: [], filter: '' };
function render() {
const q = state.filter.trim().toLowerCase();
const items = q
? state.members.filter(m =>
(m.DisplayName || '').toLowerCase().includes(q) ||
(m.UserPrincipalName || '').toLowerCase().includes(q))
: state.members;
const list = document.getElementById('mList');
document.getElementById('grpCount').textContent = state.members.length;
setTitle(document.getElementById('grpName').textContent, state.members.length);
if (state.members.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-title">Keine Mitglieder</div><div class="empty-text">Diese Gruppe hat keine direkten Benutzer.</div></div>`;
return;
}
if (items.length === 0) {
list.innerHTML = `<div class="empty-state small"><div class="empty-title">Keine Treffer</div><div class="empty-text">Filter zuruecksetzen.</div></div>`;
return;
}
list.innerHTML = items.map(m => {
const upn = m.UserPrincipalName ? `<div class="m-upn">${esc(m.UserPrincipalName)}</div>` : '';
const icon = `<a class="entra-link entra-link-static" href="${ENTRA_USER_URL(m.Id)}" target="_blank" rel="noopener" title="In Entra oeffnen">${ENTRA_ICON_SVG}</a>`;
return `<div class="m-row">
<div class="m-info">
<div class="m-name">${esc(m.DisplayName || '?')}${icon}</div>
${upn}
</div>
</div>`;
}).join('');
}
async function load(force = false) {
if (!groupId) {
document.getElementById('mList').innerHTML = `<div class="empty-state"><div class="empty-title">Keine Gruppe</div><div class="empty-text">In der URL fehlt der Parameter <code>?id=...</code>.</div></div>`;
return;
}
const titleEl = document.getElementById('grpName');
titleEl.textContent = groupName || 'Mitglieder werden geladen...';
document.getElementById('grpId').textContent = groupId;
document.getElementById('entraLink').href = ENTRA_GROUP_URL(groupId);
document.getElementById('mList').innerHTML = `<div class="m-loading"><div class="spinner"></div><span>Lade Mitglieder...</span></div>`;
try {
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members${force ? '?refresh=1' : ''}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
state.members = (data.items || [])
.sort((a, b) => (a.DisplayName || '').localeCompare(b.DisplayName || '', 'de', { sensitivity: 'base' }));
titleEl.textContent = groupName || `Gruppe ${groupId.slice(0, 8)}`;
render();
} catch (e) {
document.getElementById('mList').innerHTML = `<div class="empty-state"><div class="empty-title">Fehler</div><div class="empty-text">${esc(e.message)}</div></div>`;
}
}
document.getElementById('mFilter').addEventListener('input', e => {
state.filter = e.target.value;
render();
});
document.getElementById('btnReload').addEventListener('click', () => load(true));
load();
</script>
</body>
</html>