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
+13
View File
@@ -0,0 +1,13 @@
# PowerShell & Windows
*.log
Thumbs.db
# Settings (may contain secrets)
# Uncomment to exclude settings:
# AppData/
# macOS
.DS_Store
# Node (if ever used)
node_modules/
+276
View File
@@ -0,0 +1,276 @@
# Changelog
Format orientiert an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solange das Tool noch in der `0.x.y`-Phase ist, sind Breaking Changes erlaubt.
---
## [0.1.24] - 2026-07-03
Navigation-Tabs, Group Management, Read-Only-Modus, App Report, CSV-Import.
### Neu
- **Navigation-Tabs** im Topbar: *App Management* | *Group Management* | *App Report* — Topbar-Grid auf 3 Spalten (Brand | Nav | Actions) erweitert
- **Group Management** als eigene View (statt Modal): Mitglieder & Export, Benutzer hinzufügen, CSV-Import — drei Tabs, linke Gruppen-Suche + rechte Aktionspane
- **Read-Only-Modus**: `clientIdRo` + `scopesRo` in Settings; Verbindung versucht RW, fällt bei Fehler auf RO zurück; `body.read-only`-CSS-Klasse blendet alle Schreib-Elemente aus (Buttons, Tabs, Formular-Bereiche)
- **−R / −A Buttons** pro App-Zeile: Gruppe direkt aus Required- oder Available-Zuweisung entfernen (mit Gruppen-Picker bei mehreren)
- **App Report** (neuer Tab): Installationszähler für alle Apps via `deviceStatuses` aggregiert; Tabelle sortierbar, Suchfilter; CSV-Export der gefilterten Ansicht
- **Geräte-Export pro App**: Button in jeder Zeile des App Reports → `GET /api/apps/devicestatus?appId=` → CSV mit Gerätename, Benutzer, Status, Fehlercode, OS-Version, Sync-Datum
- **CSV-Import** im Group Management: UPNs/E-Mails einfügen oder CSV hochladen; automatische Spalten-Erkennung (Header `UserPrincipalName`/`Mail`/`Email`/`UPN`; Quoted-Fields; `,`/`;`/Tab); Graph-Batch-Lookup; Vorschau-Tabelle mit ✓/✗; Ergebnis-Banner mit farbigen Chips (hinzugefügt / bereits Mitglied / Fehler / nicht gefunden)
- **Toast-Z-Index** auf 3000 angehoben (war 1000 → lag hinter Modals mit z-index 1500)
- **Neuer Endpoint** `POST /api/groups/resolve-upns` — UPN-Liste → UserId + DisplayName via $batch
- **Neuer Endpoint** `GET /api/apps/devicestatus?appId=` — Geräte-Installationsstatus einer App
### Geändert
- **Group Management Modal** zur eigenständigen View umgebaut — keine doppelten IDs mehr
- **`openGroupExportModal()`** leitet jetzt auf `switchMainView('groups')` um
- **`switchMainView(view)`** verwaltet alle drei Views (apps / groups / report)
- **`applyReadOnlyMode()`** schaltet aktiven Schreib-Tab automatisch auf Export zurück wenn Read-Only einsetzt
- **Settings-Modal**: Felder `setClientIdRo` und `setScopesRo` hinzugefügt
- **`GET /api/status`**: liefert jetzt `readOnly: bool`
- **`POST /api/connect`**: setzt `$script:State.ReadOnly` nach RO-Fallback
- **`POST /api/disconnect`**: setzt `ReadOnly = $false` zurück
- **App Report Backend**: mehrfach überarbeitet — `installSummary` (400 für alle App-Typen ohne `DeviceManagementManagedDevices.Read.All`), `getAppInstallSummaryReport` (404 in beta und v1.0 ohne MDM-Berechtigung) → finaler Ansatz: `deviceStatuses` via `$batch` aggregiert
### Behoben
- **`$script:State.ReadOnly` Property-Fehler**: `[pscustomobject]` erlaubt keine dynamischen Properties — `ReadOnly = $false` musste in der Definition in `Start.ps1` stehen
- **Duplicate-ID-Bug**: `gexSearch`, `gexMembersArea` etc. existierten doppelt (alt: im Modal-Body, neu: in `#groupMgmtView`) — Modal-Body geleert
### Technisch
- `src/Models.ps1`: `clientIdRo`, `scopesRo` als optionale Settings-Felder
- `src/Api.ps1`: `Sync-ConfigFromSettings` + `Get-StatusEndpoint` + `Invoke-ConnectEndpoint` + `Invoke-DisconnectEndpoint` für RO-Logik erweitert; `Get-AppInstallReportEndpoint` (deviceStatuses-Aggregation); `Get-AppDeviceStatusEndpoint`; `Resolve-UpnsEndpoint`
- `www/styles.css`: `.topbar-nav`, `.nav-tab`, `.group-mgmt-view`, `.app-report-view`, `.app-report-table`, `.sortable`, `.btn-report-device-export`, `.csv-import-*`, `.csv-result-*`; `body.read-only`-Regeln erweitert um `#gexPaneImport` und `[data-tab="import"]`
---
## [0.1.23] - 2026-05-21
Setup-Datei nativer Apps aktualisierbar + Microsoft-Graph-Audit-Fixes.
### Neu
- **Setup-Datei aktualisieren** (Phase 1: `.intunewin` / win32LobApp): im App-Detail neuer Abschnitt *Setup-Datei* mit Button, der eine neue Setup-Datei von einem lokalen Server-Pfad hochlaedt und als neue contentVersion aktiviert — inkl. optionaler neuer Versionsnummer (`displayVersion`). Voller Graph-Upload-Flow: contentVersion → file → Azure-Blob-Chunk-Upload → commit → committedContentVersion. Vendor-verwaltete Apps (PMPC/Robopack) sind ausgeschlossen. MSI/MSIX folgen in spaeteren Phasen
- **Kategorie-Filter** in der App-Liste: neuer Dropdown, der sich dynamisch aus den vorkommenden App-Kategorien fuellt (inkl. *Ohne Kategorie*)
- **Kategorien-Anzeige** im App-Detail (immer sichtbar; "Default" wenn keine zugewiesen)
- **Mehrere Abteilungs-Praefixe** konfigurierbar (z.B. `abt-hm` *und* `abt-extern` parallel): neues Schema `departments.prefixes[]`, Settings-UI mit Textarea (eine pro Zeile). Der Graph-Filter wird OR-verkettet, ein einziger Listen-Request. Alter Single-String `prefix` wird weiter gelesen (Backward-Compat); Test-Endpoint zeigt Trefferzahl pro Praefix
### Geändert
- **App-Liste laedt `$expand=categories`** mit — `categories` ist eine Graph-Navigation-Property und kommt sonst leer (`null`) zurueck
- **Default-Scope** `DeviceManagementApps.Read.All` → `DeviceManagementApps.ReadWrite.All`: assign/rename/delete/content-update auf mobileApps brauchen ReadWrite (verifiziert via msgraph-Skill gegen den offiziellen Permission-Index)
### Behoben
- **429/503-Throttling**: neuer Retry-Wrapper `Invoke-MgGraphRequestRetry` mit `Retry-After`-Honorierung + Backoff (2/5/10s) in `Get-GraphPaged` (alle Lese-Pfade) + `Add-GraphMember` (Bulk-Member-Add). 403/404 werden unveraendert durchgereicht
- **Kategorien** wurden nie angezeigt — Ursache war die fehlende `$expand`-Klausel (s.o.), nicht das Frontend
### Technisch
- Neue Graph-Funktionen: `Read-IntuneWinPackage`, `New-GraphAppContentVersion`, `New-GraphAppContentFile`, `Wait-GraphContentFileState`, `Send-GraphContentToAzureBlob`, `Invoke-GraphContentRenewUpload`, `Invoke-GraphContentCommit`, `Update-GraphAppContent`, `Invoke-MgGraphRequestRetry`
- Neuer Endpoint: `POST /api/apps/{id}/content` (`Update-AppContentEndpoint`), Body `{ filePath, displayVersion? }`
- Intune-Content-Verschluesselung dokumentiert (AES-256-CBC + HMAC-SHA256, Layout `[HMAC|IV|Ciphertext]`, ProfileVersion1) — fuer `.intunewin` aus Detection.xml uebernommen
- `api()`-Helper unterstuetzt jetzt `timeoutMs` (AbortController) fuer lange Upload-Requests
---
## [0.1.22] - 2026-05-19
Robopack als zweiter Vendor analog zu PatchMyPC. Detection-Regeln wurden vom Hardcode in eine konfigurierbare Vendor-Registry verlagert.
### Neu
- **Robopack-Integration**: eigenes Source-Logo (`robopack.png`), Portal-Link auf `https://app.robopack.com/`, Lock-Icons fuer Delete/Rename, Filter-Eintrag "Nur Robopack-Apps". Default-Detection: `developer equals "Robopack"`
- **Vendor-Registry in `settings.json`**: neue Sektion `vendors[]` mit `id`, `displayName`, `portalUrl`, `logoFile`, `detection.field` (commandLine | developer | publisher | displayName | notes | owner), `detection.match` (contains | equals | regex | startsWith), `detection.pattern`, `block.delete`, `block.rename`. Mehrere Vendoren moeglich, erster Match gewinnt
- **Dynamisches Filter-Dropdown**: pro konfiguriertem Vendor wird eine Option `Nur <Name>-Apps` zur Laufzeit erzeugt
- **Vendor-Counts im Konfig-Test**: `Verbindung + Lookups testen` zeigt jetzt pro Vendor, wie viele Apps in der aktuellen Liste matchen — 0-Treffer warnen mit Hinweis auf die Detection-Regel
- **Letter-Fallback fuers Vendor-Logo**: wenn die Logo-Datei fehlt, zeigt der Badge den ersten Buchstaben des Vendor-Namens als Pille
### Geändert
- **Detection-Logik in `src/Graph.ps1`**: `Format-AppForFrontend` ruft `Resolve-AppVendorSource` mit `Test-AppVendorRule` auf, statt hardcoded `match "PatchMyPC"`. `Source`-String entspricht `vendor.displayName` ("PatchMyPC", "Robopack", ...) — bestehende PMPC-Datensaetze unveraendert
- **Backend-Blocking generisch** (`Remove-AppEndpoint`, `Update-AppEndpoint`): pro Vendor pruefen ob `block.delete`/`block.rename` gesetzt ist, HTTP 409 mit `code = "<vendorId>Managed"` (z.B. `patchmypcManaged`, `robopackManaged`)
- **Frontend-Render generisch**: Source-Badge, Delete-Lock und Rename-Lock werden aus dem Vendor-Eintrag (`SettingsState.vendorsByDisplayName`) abgeleitet — keine PMPC-Hardcodes mehr im UI
- **`code`-Format geaendert** von `PatchMyPCManaged` zu `patchmypcManaged` (kleines `id` + `Managed`-Suffix) — Frontend-Stellen, die den exakten String pruefen, sind keine vorhanden
### Behoben
- (keine; reine Feature-Erweiterung)
### Technisch
- Neue PowerShell-Funktionen: `Test-AppVendorRule`, `Resolve-AppVendorSource` (in `src/Graph.ps1`), `Find-VendorBySource` (in `src/Api.ps1`)
- Neue JS-Funktionen: `rebuildVendorIndexes(settings)`, `rebuildVendorFilterOptions()` (in `www/app.js`)
- Neue CSS-Klassen: `.app-source.app-source-letter` (Letter-Fallback), `.set-test-icon.warn` (gelbe Warn-Pille im Konfig-Test)
- Asset hinzu: `robopack.png` (Projekt-Root, ca. 53 KB)
- Migration: `Merge-Settings` ergaenzt fehlende `vendors`-Sektion aus den Defaults — bestehende `settings.json` bekommt PMPC + Robopack automatisch dazu
---
## [0.1.21] - 2026-05-19
Multi-Tenant-Readiness: das Tool ist jetzt fuer fremde Tenants ausrollbar, ohne dass HanseMerkur-spezifische Werte im Code als Defaults erscheinen.
### Neu
- **First-Run-Onboarding**: Bei leerem Tenant/Client/Praefix oeffnet sich das Settings-Modal automatisch beim Start. Setup-Banner im Modal und im Connect-Panel; Connect-Button bleibt disabled bis die kritischen Felder gefuellt sind
- **Konfiguration testen**: Neuer Button im Settings-Modal sowie neuer Endpoint `POST /api/settings/test` — prueft die aktuell eingetragenen Werte (auch vor dem Speichern) gegen Microsoft Graph: Verbindung, Abteilungs-Gruppen-Lookup mit Trefferzahl + Sample, RPA-Gruppen mit Found/Missing-Liste, User-Such-Probe. Inline-Ergebnis mit OK/FAIL-Pillen
- **Setup-Hilfetexte** unter den Connection-Feldern: Wo finde ich Tenant ID, Client ID, welche Redirect-URI ist noetig
### Geändert
- **Defaults neutralisiert** (`Get-DefaultSettings` in `src/Models.ps1`): `tenantId`, `clientId`, `departments.prefix` sind leer, `rpa.groupNames = @()`. Scopes/Naming/Theme bleiben generisch. Bestehende `settings.json` werden ueber Merge-Settings unveraendert geladen
- **Hilfedatei generisch**: `abt-hm-bd24-controlling` und `intune-win-app-adobe-reader-required` durch Platzhalter (`<dept-prefix>-team-controlling`, `intune-win-app-<app-slug>-required`) ersetzt; "Default: abt-hm"-Hinweis entfernt
### Behoben
- **Backend-Fallbacks entfernt**: `Get-GroupsEndpoint` liefert HTTP 412 + `code = "SettingsRequired"` wenn Praefix leer (vorher stiller `abt-hm`-Fallback). `Get-RpaGroupsEndpoint` liefert `notConfigured = $true` statt HM-RPA-Namen einzusetzen — Konfig-Probleme werden sichtbar statt kaschiert
- **PowerShell 5.1-Kompatibilitaet** (`src/Server.ps1`): `Read-RequestBody` nutzt `-AsHashtable` nur ab PS 6, in 5.1 wird ohne den Switch geparst. Endpoints handhaben sowohl Hashtable als auch PSCustomObject. Vorher: POST/PUT mit Body brach in Windows PowerShell 5.1 mit "Parameter 'AsHashtable' nicht gefunden"
- **Router-Logger-Bug** (`src/Router.ps1:33`): fehlende Parens um den `-f`-Format-Operator → PowerShell band `-f` als Prefix von `-ForegroundColor` und versuchte den Zeitstempel in eine ConsoleColor-Enum zu konvertieren. Folge: Unknown-Endpoint-Responses warfen 500er statt 404. Fix wie in Line 13/42 mit Klammern
### Technisch
- Neuer Endpoint: `POST /api/settings/test` (Body optional — testet uebergebene Werte gegen Graph, ohne sie zu speichern)
- Neue CSS-Klassen: `.banner-warning`, `.settings-test-result`, `.set-test-line`, `.set-test-icon.ok|fail`
---
## [0.1.20] - 2026-05-19
Konsolidierter Stand aller bisherigen Iterationen. Erster offiziell versionierter Release.
### Neu — App-Verwaltung
- **App-Zuweisungs-Workflow** mit Session-Konzept: mehrere Apps nacheinander Pillen klicken → alle Vorgänge in einem Rutsch ausführen → HTML-Report
- **App umbenennen** via Stift-Icon (Modal mit alter/neuer Name)
- **App löschen** via Trash-Icon (mit Bestätigung; bei Graph-Fehler wird Original-Message + Relationship-Hinweis ausgegeben)
- **PatchMyPC-Apps**: Rename + Delete blockiert (Lock-Icon + HTTP 409 mit `code: PatchMyPCManaged`)
### Neu — Zuweisungen
- **Zuweisung-Erstellen-Dialog** mit 6 Optionen in zwei Spalten:
- Required: Neue Gruppe / All Users / All Devices
- Available: Neue Gruppe / All Users / All Devices
- **Aktive Native-Zuweisungen werden ausgegraut** — keine Duplikate möglich
- **Available-/Required-Gruppen-Naming** unabhängig konfigurierbar (Präfix + Suffix)
- **Zuweisung entfernen** direkt im App-Detail oder im Picker-Popover (Trash-Icon pro Gruppe)
- **Native Zuweisungen** (All Users / All Devices) per `POST /api/apps/{id}/assignments`
### Neu — App-Detail-Panel
- **2-Spalten-Layout** statt 3 (Auto-Fit), Wide-Sektionen für lange Inhalte
- **Markdown-Rendering** der App-Beschreibung (Tabellen, Listen, Code-Blöcke) via gebundeltes [marked v15](https://github.com/markedjs/marked)
- **Installations-Statistik** je App (Geräte + Benutzer × Installiert / Fehler / Pending / N/A) mit Erfolgsquote-Pille
- **Abhängigkeiten** mit SVG-Flow-Diagramm (orthogonale Linien, klickbare Knoten) + Klartext-Erklärung pro Relation
- **Supersedence** analog mit Klartext-Erklärung (update / replace)
- **Klick auf Flow-Knoten** springt zur Ziel-App und expandiert sie (mit Highlight-Flash)
### Neu — Filter & Suche
- **Datums-Range-Filter** für "Zuletzt geändert" (via gebundeltes [flatpickr v4](https://flatpickr.js.org/), deutsche Lokalisierung)
- **Preset-Dropdown** (Alle / Ohne Zuweisungen / Nur Available / Nur Required / Mehrere / All Users / All Devices / Intune / PatchMyPC)
- **Checkbox-Chips** "Bereits Gruppenmitglied" und "All Users/Devices ausblenden"
### Neu — Einstellungen
- **Settings-Cog** im Header öffnet Modal mit:
- Connection: Tenant-ID, Client-ID, Scopes
- Abteilungs-Gruppen: konfigurierbarer Präfix (Default `abt-hm`)
- RPA-Gruppen: explizite Liste
- Benutzer-Suche: konfigurierbare Felder (DisplayName / UPN / Mail / Department)
- Required + Available Naming-Schema (Präfix + Suffix)
- Branding: Logo-Upload (PNG/JPG/SVG/WEBP/GIF, max 2 MB)
- Farben: 8 Akzentfarben + "Standardfarben wiederherstellen"
- Persistenz in `%APPDATA%\IntuneAppManager-Web\settings.json`
- **WCAG-Kontrast-Algorithmus** wählt automatisch Text-/Icon-Farbe (Schwarz/Weiß) je nach gewähltem Tint
- **Surface-Shade-Algorithmus** leitet harmonische innere Surfaces aus der gewählten Detail-Panel-Farbe ab
### Neu — Branding
- **Default-Logo** `application.png` im Projekt-Root wird angezeigt, sofern kein eigenes hochgeladen wurde
- **Brand-Farbe** ist eigene Konzeptfarbe (separat von Available) — Logo-Hintergrund und Stepper-Indikator
- **Avatar** im Header: Bootstrap `person-circle`-Icon, eingefärbt in Brand-Primary, mit pulsierendem grünem Online-Dot
- **Logout** als Türsymbol mit Pfeil
### Neu — Header
- **Cog-Icon** für Einstellungen
- **Fragezeichen-Icon** für Hilfe
- **Theme-Toggle** als Sun/Mond-Slider
- **Avatar** mit Tooltip (vollständiger UPN)
- **Logout-Icon** (statt Text-Button)
### Neu — Hilfe-System
- **Hilfe-Modal** (1320 px breit) mit:
- TOC-Sidebar (auto-generiert aus H2-Headings)
- Aktiver Eintrag wird via Scroll-Position highlighted
- Inhalt aus `www/help.md` (Markdown, jederzeit ohne Code-Änderung anpassbar)
- Inhalt: Schnellstart, Hauptworkflow, Header, Spalten, App-Detail, Einstellungen, Einschränkungen, Tastatur, Fehlerbehebung
### Neu — Empfänger-Liste
- **Drei Modi**: Abteilungen, RPA-Gruppen, Einzel-Benutzer — frei mischbar
- **Gruppen aufklappen** (Pfeil rechts) → Mitglieder inline mit Suchfilter
- **Teams-Icon** bei jedem User → Direkt-Chat in Teams-Desktop-App
- **Externer-Link- und Members-Tab-Icons** öffnen direkt im Intune-Portal
- **Icons immer sichtbar** (vorher hover-only)
### Neu — Backend
- **Graph `$batch`-Optimierung** für App-Details: 3 Sub-Calls in 1 HTTP-Roundtrip, server-seitig parallelisiert
- **PowerShell-Module-Pre-Flight** in `Start.ps1`: prüft, prompted, installiert fehlende Module im User-Scope
- **WAM-Window-Focus-Helper**: Auth-Popup wird automatisch nach vorne geholt (Background-Runspace)
- **HTML-Apply-Report** in `%TEMP%\IntuneAppManager-Reports\` mit Erfolg/Fehler-Tabelle nach App gruppiert
- **Caches**: Apps + Groups + Membership im Server-Process. Selektive Invalidierung bei Settings-Änderungen
- **Run.cmd** als plattform-unabhängiger Launcher; PowerShell-Fenster startet minimiert
### Geändert
- **App-Row-Layout**: schmalere Type-/Version-Spalten, breitere Name-Spalte; Icons rechtsbündig vertikal mittig
- **Filter-Chips**: keine Pillen-Rahmen mehr, mehr Gap zwischen Chips (20 px)
- **App-Namen**: brechen jetzt um statt zu truncieren (kein `…` mehr)
- **Detail-Panel**: max 2 Spalten statt Auto-Fit, lange Inhalte als Wide-Sektion
- **Modal-Höhe**: max 85 vh statt 60 vh, kein Scrollen für übliche Dialog-Inhalte
- **Stepper** im Header entfernt (war redundant)
- **Default-Farben**: Brand `#27a078`, Required `#cb2a7a`, RowSelected `#71e5c4`, DetailPanel `#f7f7f7`
### Behoben
- **PowerShell Single-Element-Array-JSON-Bug**: `@()` am Call-Site erzwingt Array-Serialisierung (sonst wurde Single-Dep als Objekt statt Array im JSON gerendert → Frontend ignorierte sie)
- **Date-Picker Blink-Effekt** beim Klick: nativer Browser-Picker ersetzt durch flatpickr (kein Chromium-Initial-Render-Quirk mehr)
- **Date-Picker Dark-Mode**: Calendar-Icon war nicht sichtbar — durch `filter: invert(1) brightness(1.4)` korrigiert
- **WAM-Auth-Fenster** versteckt hinter anderen Fenstern → Parallel-Runspace holt es nach vorne
- **Browser-Cache**: aggressive `no-store`-Header für HTML/JS/CSS sorgen für sofortige Updates
- **Transparenz-Slider** komplett entfernt — picked color = solid background (war zuvor verwirrend)
- **Logo-Cache** zweiter Upload greift sofort (mtime als Cache-Bust-Tag)
- **Settings-Save**: Selektive Cache-Invalidierung statt aller Caches (Logo-Update reloaded nicht mehr die Apps)
- **`jumpToAppById`**: triggert jetzt den Details-Fetch (vorher: ewiger Spinner + toggleAppExpand-Konflikt → "App geht nicht auf")
- **Portal-URLs**: Group-Links auf `intune.microsoft.com/.../GroupDetailsMenuBlade/.../menuId/` korrigiert (vorher `entra.microsoft.com` ohne `/menuId/`-Suffix)
- **App-Name-Icons** vertikal mittig zur ganzen Row (nicht nur zur Name-Line)
- **Type/Version-Chips in markierter Row**: Kontrast korrekt (vorher unleserlich)
- **Stepper-Reste**: Position der Header-Actions wieder rechts
- **Connect-Endpoint**: bessere Fehlermeldung mit echter Graph-Antwort statt nur "Ungültige Anfrage"
### Entfernt
- **Workflow-Stepper** im Header (Verbinden → App-Zuweisungen → Apps zuweisen → Ausführen)
- **Transparenz-Slider** in den Theme-Color-Settings
- **`members.html`-Standalone-Seite**: ersetzt durch direkten Intune-Portal-Link
- **Native Date-Inputs** (`type="date"`): ersetzt durch flatpickr
- **`color-scheme: light`**-Workaround für Date-Picker (nicht mehr nötig nach flatpickr-Integration)
### Bundled Libraries
- `marked v15.0.12` (MIT) — Markdown-Renderer für App-Beschreibung + Help-Page
- `flatpickr v4.6.13` (MIT) — Date-Range-Filter
### Technisch
- **PowerShell-Server**: HttpListener, Single-Thread Request-Loop, ausreichend für Single-Admin-Tool
- **Frontend**: Vanilla JS + CSS, keine Frameworks
- **Persistenz**: JSON-Datei in `%APPDATA%\IntuneAppManager-Web\settings.json`
- **Cache-Strategy**: Apps + Gruppen + GroupMembers im PS-Prozess; Theme + Logo per CSS-Variable zur Laufzeit überschrieben
+217
View File
@@ -0,0 +1,217 @@
# Intune Manager – Web Edition
Modernes Web-Frontend fuer den Intune Manager.
PowerShell startet einen lokalen HTTP-Server, der das Frontend ausliefert
und die Microsoft Graph-Aufrufe ausfuehrt.
## Voraussetzungen
- Windows mit PowerShell 5.1 oder PowerShell 7+
- Modul `Microsoft.Graph.Authentication` (wird beim ersten Connect benoetigt):
```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
```
- Browser (Edge, Chrome, Firefox)
## Start
```powershell
.\Start.ps1
```
Optionen:
| Parameter | Standard | Beschreibung |
|---------------|----------|-----------------------------------------------------------|
| `-Port` | `8077` | Port des lokalen HTTP-Servers |
| `-TenantId` | — | Ueberschreibt Tenant-ID aus Settings (nur dieser Start) |
| `-ClientId` | — | Ueberschreibt Client-ID aus Settings (nur dieser Start) |
| `-NoBrowser` | aus | Browser nicht automatisch oeffnen |
Beispiele:
```powershell
.\Start.ps1 -Port 8088
.\Start.ps1 -NoBrowser
```
Beenden: `Ctrl+C` im Terminal.
---
## Verbindungsmodus (Read/Write vs. Read-Only)
Der Server probiert beim Verbinden **zuerst die Read/Write-App-ID** (`clientId`).
Schlaegt diese fehl, wird automatisch auf die **Read-Only-App-ID** (`clientIdRo`) zurueckgefallen.
| Modus | Anmelde-Ergebnis | Verhalten im Frontend |
|-------------|------------------------------------------|------------------------------------------------|
| Read/Write | Normale Verbindung | Alle Funktionen verfuegbar |
| Read-Only | Fallback auf `clientIdRo` | Schreib-Buttons/Tabs werden ausgeblendet |
### Was wird im Read-Only-Modus ausgeblendet?
- App-Zuweisung (Required / Available hinzufuegen und entfernen)
- Gruppen erstellen und loeschen
- Tab „Benutzer hinzufuegen" im Group Management
- Tab „CSV-Import" im Group Management
- Session-Ausfuehren-Button
- Alle Rename-/Delete-Buttons
### Konfiguration in den Einstellungen
```
Settings → Verbindung
Client-ID (Read/Write): <Azure App-Registration mit Schreibrechten>
Client-ID (Read-Only): <Azure App-Registration nur mit Leserechten> (optional)
Scopes RW: DeviceManagementApps.ReadWrite.All ...
Scopes RO: DeviceManagementApps.Read.All ...
```
### Benoetigt Graph-Berechtigungen
| Funktion | Benoetigt |
|----------------------------------|----------------------------------------------|
| Apps anzeigen | `DeviceManagementApps.Read.All` |
| Gruppen anzeigen / Mitglieder | `GroupMember.Read.All` |
| User suchen | `User.Read.All` |
| Zuweisungen aendern | `DeviceManagementApps.ReadWrite.All` |
| Gruppen-Mitglieder aendern | `GroupMember.ReadWrite.All` |
| App-Installationszaehler (Report)| `DeviceManagementManagedDevices.Read.All` ⚠ |
| Geraete-Exportliste pro App | `DeviceManagementManagedDevices.Read.All` ⚠ |
> ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App-Report nur
> Zaehler = 0 und der Geraete-Export liefert keine Eintraege.
---
## Navigation (Tabs)
| Tab | Beschreibung |
|--------------------|-------------------------------------------------------------|
| App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen |
| Group Management | Mitglieder anzeigen, exportieren, Benutzer hinzufuegen, CSV-Import |
| App Report | Installationszaehler pro App, Geraete-Export als CSV |
---
## Group Management
### Mitglieder & Export
- Gruppe links suchen und auswaehlen
- Mitgliederliste (inkl. verschachtelte Gruppen) wird geladen
- Filter zum Einschraenken der Anzeige
- **Als CSV exportieren** laedt alle Mitglieder als `members-<Gruppe>-<Datum>.csv`
### Benutzer hinzufuegen *(nur Read/Write)*
- Benutzer per Name, UPN oder E-Mail suchen
- Mehrere Benutzer auswaehlen und gemeinsam hinzufuegen
### CSV-Import *(nur Read/Write)*
Massenhaftes Hinzufuegen von Benutzern zu einer Gruppe per CSV-Datei.
**Ablauf:**
1. Gruppe links auswaehlen
2. CSV hochladen **oder** UPNs/E-Mails direkt einfuegen (eine pro Zeile)
3. **„Benutzer aufloesen"** – Graph-Lookup via UPN/Mail → Vorschau mit ✓/✗
4. **„Gefundene importieren"** – fuegt alle gefundenen Benutzer zur Gruppe hinzu
**CSV-Format:**
Die erste Zeile wird als optionaler Header erkannt.
Spalte mit dem Namen `UserPrincipalName`, `Mail` oder `Email` wird automatisch
ausgewaehlt. Ohne Header: erste Spalte mit `@` wird verwendet.
```csv
DisplayName,UserPrincipalName,Mail
Max Mustermann,max.mustermann@firma.de,max.mustermann@firma.de
Erika Muster,erika.muster@firma.de,
```
Oder einfach eine UPN pro Zeile:
```
max.mustermann@firma.de
erika.muster@firma.de
```
**Ergebnis-Anzeige nach Import:**
| Chip / Status | Bedeutung |
|--------------------|------------------------------------------------|
| ✔ Gruen | Erfolgreich hinzugefuegt |
| ≈ Gelb | Bereits Mitglied (kein Fehler) |
| ✗ Rot | Fehler (Meldung in der Tabelle) |
| Grau | UPN nicht in Graph gefunden (nicht importiert) |
---
## App Report
- **Neu laden**: laedt Installationszaehler fuer alle Apps (kann 1–2 Min. dauern)
- Tabelle sortierbar per Klick auf Spaltenheader
- Suche filtert nach App-Name, Publisher, Typ
- **Als CSV exportieren**: exportiert die gefilterte Tabelle
- **Geraete-Export** (pro Zeile): laedt alle Geraete mit Installationsstatus fuer diese App
---
## Architektur
```
Intune Manager/
├── Start.ps1 Entry Point, Config, State
├── README.md diese Datei
├── src/
│ ├── Models.ps1 Settings-Schema und Defaults
│ ├── Graph.ps1 Microsoft Graph API Helpers
│ ├── Server.ps1 HTTP-Server (HttpListener)
│ ├── Router.ps1 Request-Routing
│ └── Api.ps1 REST-API-Endpoints
└── www/
├── index.html SPA-Shell
├── styles.css Dark Theme, Read-Only-Regeln
└── app.js Frontend-Logik (Vanilla JS)
```
---
## REST-API (intern)
| Methode | Pfad | Rechte | Zweck |
|---------|-------------------------------------------|-----------|------------------------------------------|
| GET | `/api/status` | — | Verbindungsstatus + ReadOnly-Flag |
| POST | `/api/connect` | — | Microsoft Graph verbinden |
| POST | `/api/disconnect` | — | Trennen |
| GET | `/api/groups` | Read | Abteilungs-Gruppen (`abt-hm-*`) |
| GET | `/api/groups/rpa` | Read | 3 vordefinierte RPA-Gruppen |
| GET | `/api/groups/search?q=` | Read | Gruppen suchen |
| POST | `/api/groups` | **Write** | Neue Required-Gruppe erstellen |
| POST | `/api/groups/check` | Read | Gruppen-Name pruefen |
| POST | `/api/groups/resolve-upns` | Read | UPN-Liste → UserId + DisplayName |
| GET | `/api/groups/{id}/members` | Read | Mitglieder einer Gruppe |
| GET | `/api/groups/{id}/members/export` | Read | Mitglieder als CSV |
| POST | `/api/groups/{id}/members` | **Write** | Benutzer zur Gruppe hinzufuegen |
| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer aus Gruppe entfernen |
| GET | `/api/users?q=` | Read | Benutzer suchen |
| GET | `/api/apps` | Read | Apps laden (cached) |
| POST | `/api/apps/refresh` | Read | Apps neu laden |
| GET | `/api/apps/report` | Read+MDM | Installationszaehler alle Apps |
| GET | `/api/apps/devicestatus?appId=` | Read+MDM | Geraete-Installationsstatus einer App |
| GET | `/api/membership?targets=&groupId=` | Read | Mitgliedschafts-Status pruefen |
| POST | `/api/membership/bulk` | Read | Mehrere Mitgliedschaften pruefen |
| POST | `/api/assignments/apply` | **Write** | Geplante Zuweisungen ausfuehren |
> **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All`
---
## Hinweise
- **Single-User-App** – der lokale HTTP-Server ist fuer einen Benutzer ausgelegt.
- **Auth lebt im PowerShell-Prozess** – Disconnect oder Schliessen des Terminals beendet die Session.
- **Cache** – Apps werden im Serverprozess gecacht. Refresh-Knopf laedt neu.
- **Port-Konflikt** – laeuft bereits eine Instanz, beendet `Start.ps1` sie automatisch (per CommandLine-Matching). Bei PID-4-Konflikt (http.sys) anderen Port waehlen: `.\Start.ps1 -Port 8088`.
+19
View File
@@ -0,0 +1,19 @@
@echo off
REM Launcher fuer Start.ps1 — funktioniert auf allen Clients, unabhaengig
REM davon ob .ps1-Dateien standardmaessig mit PowerShell oder Notepad
REM verknuepft sind. Verknuepfungen koennen direkt auf diese .cmd zeigen.
REM
REM -NoProfile : keine User-Profile-Skripte laden (schneller, sauberer)
REM -ExecutionPolicy : Bypass nur fuer diesen Prozess, keine Systemaenderung
REM -WindowStyle Minimized : PowerShell-Fenster startet minimiert (Taskleiste).
REM Browser oeffnet sich trotzdem; bei Bedarf kann das
REM PS-Fenster ueber die Taskleiste sichtbar gemacht
REM werden (z.B. fuer Live-Logs).
REM -File ... %* : Start.ps1 ausfuehren, alle Argumente durchreichen
REM
REM Beispielaufrufe:
REM Run.cmd
REM Run.cmd -Port 8088
REM Run.cmd -NoBrowser
start "Intune Manager" /min powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%~dp0Start.ps1" %*
+224
View File
@@ -0,0 +1,224 @@
# Intune Manager - Web Edition
# Startet einen lokalen HTTP-Server und oeffnet das moderne Web-Frontend.
#
# Aufruf:
# .\Start.ps1
# .\Start.ps1 -Port 8088 -NoBrowser
# .\Start.ps1 -TenantId "..." -ClientId "..."
[CmdletBinding()]
param(
[int]$Port = 8077,
# Optional: ueberschreibt die persistierten Settings nur fuer diesen Lauf.
# Wenn nicht gesetzt -> Werte aus %APPDATA%\IntuneAppManager-Web\settings.json
# (bzw. den Defaults beim Erststart) werden verwendet.
[string]$TenantId = "",
[string]$ClientId = "",
[switch]$NoBrowser,
[switch]$AutoInstall, # ohne Rueckfrage installieren
[switch]$SkipModuleCheck # Pre-Flight ueberspringen (z.B. CI)
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
# ============================================================
# Pre-Flight: PowerShell-Module pruefen / installieren
# (nur User-Scope, nichts systemweites)
# ============================================================
function Test-RequiredModules {
[CmdletBinding()]
param(
[hashtable[]]$Required,
[switch]$AutoInstall
)
Write-Host "Pruefe PowerShell-Module..." -ForegroundColor Cyan
$missing = @()
$outdated = @()
foreach ($req in $Required) {
$name = $req.Name
$minV = [version]$req.MinVersion
$available = @(Get-Module -Name $name -ListAvailable -ErrorAction SilentlyContinue)
if ($available.Count -eq 0) {
Write-Host " [FEHLT] $name (>= $minV)" -ForegroundColor Yellow
$missing += $req
} else {
$maxV = ($available | Sort-Object Version -Descending | Select-Object -First 1).Version
if ($maxV -lt $minV) {
Write-Host " [ALT] $name v$maxV (<$minV)" -ForegroundColor Yellow
$outdated += @{ Name = $name; Have = $maxV; Need = $minV }
} else {
Write-Host " [OK] $name v$maxV" -ForegroundColor Green
}
}
}
if ($missing.Count -eq 0 -and $outdated.Count -eq 0) {
return $true
}
# NuGet-Provider sicherstellen, sonst schlaegt Install-Module schweigend fehl
$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
try {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -ErrorAction Stop | Out-Null
} catch {
throw "NuGet-Provider konnte nicht installiert werden: $($_.Exception.Message)"
}
}
# PSGallery als trusted markieren, sonst kommt fuer jede Install-Aktion ein Prompt
$gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue
if ($gallery -and $gallery.InstallationPolicy -ne 'Trusted') {
try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } catch {}
}
if (-not $AutoInstall) {
Write-Host ""
Write-Host "Folgendes wird im User-Scope (CurrentUser) installiert/aktualisiert:" -ForegroundColor Cyan
foreach ($m in $missing) { Write-Host " + $($m.Name) min. $($m.MinVersion)" -ForegroundColor White }
foreach ($o in $outdated) { Write-Host " ~ $($o.Name) v$($o.Have) -> min. $($o.Need)" -ForegroundColor White }
Write-Host ""
$answer = Read-Host "Jetzt automatisch installieren? [J]a / [N]ein"
if ($answer -notmatch '^(j|J|y|Y)') {
Write-Host ""
Write-Host "Abbruch. Du kannst die Module manuell installieren:" -ForegroundColor Yellow
foreach ($m in $missing) { Write-Host " Install-Module $($m.Name) -Scope CurrentUser -Force" -ForegroundColor Gray }
foreach ($o in $outdated) { Write-Host " Update-Module $($o.Name) -Scope CurrentUser -Force" -ForegroundColor Gray }
return $false
}
}
foreach ($m in $missing) {
Write-Host ""
Write-Host "Installiere $($m.Name) (CurrentUser)..." -ForegroundColor Cyan
try {
Install-Module -Name $m.Name -MinimumVersion $m.MinVersion -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Write-Host " -> OK" -ForegroundColor Green
} catch {
Write-Host " -> Fehler: $($_.Exception.Message)" -ForegroundColor Red
return $false
}
}
foreach ($o in $outdated) {
Write-Host ""
Write-Host "Aktualisiere $($o.Name) auf min. $($o.Need)..." -ForegroundColor Cyan
try {
Install-Module -Name $o.Name -MinimumVersion $o.Need -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Write-Host " -> OK" -ForegroundColor Green
} catch {
Write-Host " -> Fehler: $($_.Exception.Message)" -ForegroundColor Red
return $false
}
}
return $true
}
if (-not $SkipModuleCheck) {
$required = @(
@{ Name = "Microsoft.Graph.Authentication"; MinVersion = "2.0.0" }
)
if (-not (Test-RequiredModules -Required $required -AutoInstall:$AutoInstall)) {
Write-Host ""
Write-Host "Server-Start abgebrochen � benoetigte Module fehlen." -ForegroundColor Red
exit 1
}
Write-Host ""
}
# Quellmodule laden (Reihenfolge wichtig)
. (Join-Path $root "src/Models.ps1")
. (Join-Path $root "src/Graph.ps1")
. (Join-Path $root "src/Api.ps1")
. (Join-Path $root "src/Router.ps1")
. (Join-Path $root "src/Server.ps1")
# Settings aus Persistenz (Datei) laden, optional per Parameter ueberschreiben
$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 �
# Connect-Endpoint und Co. lesen weiterhin $script:Config, das nach jedem
# Settings-Save aktualisiert wird.
$script:Config = @{
TenantId = $script:Settings.connection.tenantId
ClientId = $script:Settings.connection.clientId
ClientIdRo = $script:Settings.connection.clientIdRo
Scopes = @($script:Settings.connection.scopes)
ScopesRo = @($script:Settings.connection.scopesRo)
WebRoot = (Join-Path $root "www")
ReportDir = (Join-Path $env:TEMP "IntuneAppManager-Reports")
}
if (-not (Test-Path $script:Config.ReportDir)) {
New-Item -ItemType Directory -Path $script:Config.ReportDir -Force | Out-Null
}
# Session-State (im PowerShell-Prozess gehalten)
$script:State = [pscustomobject]@{
Connected = $false
ReadOnly = $false
Account = $null
TenantId = $null
Groups = @() # abt-hm Gruppen
RpaGroups = @()
Apps = @()
Users = @{} # cache by id
GroupMembers = @{} # cache groupId -> member ids
Session = @() # geplante Zuweisungen
}
$script:ToolVersion = "0.1.23"
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host ""
Write-Host "==============================================" -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
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
# das alte Fenster bedient weiter mit altem Code. Darum alle anderen Start.ps1-
# Prozesse DIESES Pfads vorher beenden, damit ein Neustart wirklich neu laedt.
# ============================================================
try {
$thisScript = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($thisScript)) { $thisScript = Join-Path $PSScriptRoot 'Start.ps1' }
# Nur echte Server-Instanzen treffen: per -File gestartet UND auf diesen
# Start.ps1-Pfad zeigend. So werden -Command-Prozesse (Diagnose etc.), die
# den Pfad nur als String enthalten, NICHT versehentlich beendet.
$stale = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -like "*$thisScript*") -and ($_.CommandLine -like "*-File*") })
foreach ($p in $stale) {
Write-Host " Beende alte Instanz (PID $($p.ProcessId))..." -ForegroundColor Yellow
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop }
catch { Write-Host " konnte PID $($p.ProcessId) nicht beenden: $($_.Exception.Message)" -ForegroundColor DarkYellow }
}
if ($stale.Count -gt 0) { Start-Sleep -Milliseconds 800 } # kurz warten bis der Port frei ist
} catch {
Write-Host " (Konnte alte Instanzen nicht pruefen: $($_.Exception.Message))" -ForegroundColor DarkGray
}
$url = "http://localhost:$Port/"
Write-Host " URL: $url" -ForegroundColor Green
Write-Host " WebRoot: $($script:Config.WebRoot)" -ForegroundColor DarkGray
Write-Host " Reports: $($script:Config.ReportDir)" -ForegroundColor DarkGray
Write-Host " Beenden: Ctrl+C" -ForegroundColor DarkGray
Write-Host ""
if (-not $NoBrowser) {
Start-Process $url
}
Start-WebServer -Port $Port
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+3008
View File
File diff suppressed because it is too large Load Diff
+1000
View File
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
# Datenklassen / Hilfen fuer Frontend-JSON
function ConvertTo-Json2 {
param([Parameter(ValueFromPipeline=$true)]$InputObject, [int]$Depth = 12)
process {
return $InputObject | ConvertTo-Json -Depth $Depth -Compress
}
}
# Normalisiert ein potentielles Datum (DateTime, DateTimeOffset, String,
# /Date(ms)/, $null) auf ein ISO-8601-String — damit JS' new Date() es
# zuverlaessig parsen kann.
function ConvertTo-IsoDate {
param($Value)
if ($null -eq $Value) { return $null }
if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('o') }
if ($Value -is [DateTimeOffset]) { return $Value.UtcDateTime.ToString('o') }
$s = [string]$Value
if ([string]::IsNullOrWhiteSpace($s)) { return $null }
# Microsoft-Legacy "/Date(1234567890123)/"
if ($s -match '^\/Date\((-?\d+)') {
try { return ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$matches[1])).UtcDateTime.ToString('o') } catch {}
}
# Schon ein parsbares Datum?
try { return ([DateTime]$s).ToUniversalTime().ToString('o') } catch {}
return $s
}
# ============================================================
# Settings: editierbare Konfiguration, persistiert als JSON
# unter %APPDATA%\IntuneAppManager-Web\settings.json
# ============================================================
function Get-SettingsPath {
$dir = Join-Path $env:APPDATA "IntuneAppManager-Web"
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
return Join-Path $dir "settings.json"
}
function Get-DefaultSettings {
# Tenant-spezifische Felder (tenantId, clientId, departments.prefix,
# rpa.groupNames) sind absichtlich leer — eine frische Installation
# zwingt den Admin durchs Setup. Generische Werte (Scopes, Naming-Schemas,
# Theme) sind branchenuebliche Startpunkte und bleiben besetzt.
return [pscustomobject]@{
connection = [pscustomobject]@{
tenantId = ""
clientId = ""
clientIdRo = ""
# DeviceManagementApps.ReadWrite.All ist Pflicht: assign (native All
# Users/Devices), PATCH (Rename) und DELETE auf mobileApps brauchen
# ReadWrite — Read.All allein liefert dort 403. Verifiziert via
# msgraph-Skill gegen den offiziellen Graph-Permission-Index.
scopes = @("Group.ReadWrite.All", "GroupMember.ReadWrite.All", "User.Read.All", "DeviceManagementApps.ReadWrite.All")
scopesRo = @("Group.Read.All", "GroupMember.Read.All", "User.Read.All", "DeviceManagementApps.Read.All")
}
departments = [pscustomobject]@{
# Ein oder mehrere Praefixe fuer den Abteilungs-Modus (linke Spalte).
# Mehrfach moeglich (z.B. "abt-hm" + "abt-extern"). Pflicht: mindestens
# ein Eintrag im Setup. Der alte Single-String 'prefix' bleibt fuer
# Rueckwaerts-Kompatibilitaet — Get-DepartmentPrefixes liest beides.
prefixes = @()
prefix = ""
}
rpa = [pscustomobject]@{
# Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis.
groupNames = @()
}
userSearch = [pscustomobject]@{
# In welchen Feldern bei der Benutzersuche gesucht wird.
# Erlaubt: displayName, userPrincipalName, mail, department.
fields = @("displayName", "userPrincipalName", "mail")
}
requiredGroupNaming = [pscustomobject]@{
# Wird beim "+ Required-Gruppe"-Workflow verwendet:
# {prefix}{slug-vom-app-name}{suffix}
prefix = "intune-win-app-"
suffix = "-required"
}
availableGroupNaming = [pscustomobject]@{
# Analog zu requiredGroupNaming — fuer "+ Available-Gruppe"-Workflow.
prefix = "intune-win-app-"
suffix = "-available"
}
branding = [pscustomobject]@{
# Logo-Dateiname relativ zum Projekt-Root (dort liegen auch
# intune.png/pmpc.png/application.png). $null = Letter "I" Fallback.
logoFile = "application.png"
}
# Vendor-Registry: jede App wird gegen diese Liste in Reihenfolge
# gepruef; erster Match gewinnt. Source-String im App-Objekt =
# vendor.displayName (sonst "Intune"). Tenants koennen Detection-
# Regeln, Portal-URLs und Blocking pro Vendor in settings.json
# ueberschreiben.
vendors = @(
[pscustomobject]@{
id = "patchmypc"
displayName = "PatchMyPC"
portalUrl = "https://portal.patchmypc.com/"
logoFile = "pmpc.png"
detection = [pscustomobject]@{
field = "commandLine" # commandLine | developer | publisher | displayName | notes | owner
match = "contains" # contains | equals | regex | startsWith
pattern = "PatchMyPC"
}
block = [pscustomobject]@{ delete = $true; rename = $true }
},
[pscustomobject]@{
id = "robopack"
displayName = "Robopack"
portalUrl = "https://app.robopack.com/"
logoFile = "robopack.png"
detection = [pscustomobject]@{
field = "developer"
match = "equals"
pattern = "Robopack"
}
block = [pscustomobject]@{ delete = $true; rename = $true }
}
)
theme = [pscustomobject]@{
# Hauptfarben fuer Highlights. Soft/Strong/Border/Bg werden im
# Frontend per rgba() aus dem Hex abgeleitet.
colors = [pscustomobject]@{
brand = "#27a078" # Primaere Firmenfarbe: Logo-Hintergrund, Stepper-Indikator
accent = "#3b82f6" # Available-Pillen, Links, Akzent-Hover
required = "#cb2a7a" # Pink (Required-Badges)
success = "#10b981" # Bereits zugewiesen
warning = "#f59e0b" # Teilweise / Skip
error = "#ef4444" # Fehler / Destructive
rowSelected = "#71e5c4" # Hintergrund der aufgeklappten/markierten App-Zeile
detailPanel = "#f7f7f7" # Hintergrund des Details-Bereichs unter der App
}
}
}
}
# Tiefer Merge: gespeicherte Werte ueberschreiben Defaults, neue Default-
# Keys werden trotzdem ergaenzt — robust gegen Settings-Schema-Erweiterungen.
function Merge-Settings {
param($Base, $Override)
if ($null -eq $Override) { return $Base }
$result = [ordered]@{}
foreach ($p in $Base.PSObject.Properties) {
$name = $p.Name
$bv = $p.Value
$ov = $null
$hasOverride = $false
if ($Override.PSObject.Properties[$name]) {
$ov = $Override.PSObject.Properties[$name].Value
$hasOverride = $true
}
if (-not $hasOverride) {
$result[$name] = $bv
} elseif ($bv -is [pscustomobject] -and $ov -is [pscustomobject]) {
$result[$name] = Merge-Settings -Base $bv -Override $ov
} else {
$result[$name] = $ov
}
}
# Zusaetzliche Properties aus Override, die nicht im Base sind, ignorieren —
# sie wuerden vom Backend ohnehin nicht gelesen.
return [pscustomobject]$result
}
function Get-DepartmentPrefixes {
# Zentrale Quelle fuer die Abteilungs-Praefixe. Bevorzugt das neue
# 'prefixes'-Array; faellt sonst auf den alten Single-String 'prefix'
# zurueck (Rueckwaerts-Kompatibilitaet mit existierenden settings.json).
# Liefert immer ein String-Array (getrimmt, dedupliziert, ohne leere
# Eintraege), ggf. leer wenn nichts konfiguriert ist.
param($Settings)
$out = [System.Collections.Generic.List[string]]::new()
if ($null -eq $Settings -or $null -eq $Settings.departments) { return ,$out.ToArray() }
$d = $Settings.departments
$candidates = @()
if ($d.PSObject.Properties['prefixes']) { $candidates += @($d.prefixes) }
if ($d.PSObject.Properties['prefix'] -and $d.prefix) { $candidates += @([string]$d.prefix) }
$seen = @{}
foreach ($p in $candidates) {
if ($null -eq $p) { continue }
$t = ([string]$p).Trim()
if (-not $t) { continue }
$k = $t.ToLowerInvariant()
if ($seen.ContainsKey($k)) { continue }
$seen[$k] = $true
$out.Add($t)
}
return $out.ToArray()
}
function Read-Settings {
$path = Get-SettingsPath
$defaults = Get-DefaultSettings
if (-not (Test-Path $path)) { return $defaults }
try {
$raw = Get-Content -Path $path -Raw -Encoding UTF8
if ([string]::IsNullOrWhiteSpace($raw)) { return $defaults }
$saved = $raw | ConvertFrom-Json
return Merge-Settings -Base $defaults -Override $saved
} catch {
Write-Host "[SETTINGS] Lesen fehlgeschlagen, verwende Defaults: $($_.Exception.Message)" -ForegroundColor Yellow
return $defaults
}
}
function Write-Settings {
param([Parameter(Mandatory=$true)]$Settings)
$path = Get-SettingsPath
$json = $Settings | ConvertTo-Json -Depth 10
[IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8)
Write-Host "[SETTINGS] Gespeichert: $path" -ForegroundColor DarkGray
}
function Get-SettingsValidationErrors {
# Rudimentaere Validierung. Schwere Fehler -> Speichern ablehnen.
param($S)
$errs = @()
if (-not $S.connection.tenantId -or $S.connection.tenantId -notmatch '^[0-9a-fA-F-]{36}$') {
$errs += "Tenant ID muss eine GUID sein."
}
if (-not $S.connection.clientId -or $S.connection.clientId -notmatch '^[0-9a-fA-F-]{36}$') {
$errs += "Client ID (Read/Write) muss eine GUID sein."
}
if ($S.connection.clientIdRo -and $S.connection.clientIdRo.Trim() -and $S.connection.clientIdRo -notmatch '^[0-9a-fA-F-]{36}$') {
$errs += "Client ID (Read Only) muss eine GUID sein (oder leer lassen)."
}
if (-not $S.connection.scopes -or @($S.connection.scopes).Count -eq 0) {
$errs += "Mindestens ein Scope erforderlich."
}
$deptPrefixes = Get-DepartmentPrefixes -Settings $S
if ($deptPrefixes.Count -eq 0) {
$errs += "Mindestens ein Abteilungs-Praefix erforderlich."
} else {
$bad = @($deptPrefixes | Where-Object { $_.Trim().Length -lt 2 })
if ($bad.Count -gt 0) { $errs += "Abteilungs-Praefixe muessen jeweils mindestens 2 Zeichen lang sein." }
}
if (-not $S.rpa.groupNames -or @($S.rpa.groupNames).Count -eq 0) {
$errs += "Mindestens eine RPA-Gruppe erforderlich."
}
$allowedUserFields = @('displayName','userPrincipalName','mail','department')
$bad = @($S.userSearch.fields | Where-Object { $_ -notin $allowedUserFields })
if ($bad.Count -gt 0) { $errs += "Unbekannte User-Search-Felder: $($bad -join ', ')" }
if (-not $S.userSearch.fields -or @($S.userSearch.fields).Count -eq 0) {
$errs += "Mindestens ein User-Such-Feld erforderlich."
}
if (-not $S.requiredGroupNaming.prefix -or -not $S.requiredGroupNaming.suffix) {
$errs += "Required-Gruppen-Naming: Praefix und Suffix erforderlich."
}
if ($S.availableGroupNaming -and (-not $S.availableGroupNaming.prefix -or -not $S.availableGroupNaming.suffix)) {
$errs += "Available-Gruppen-Naming: Praefix und Suffix erforderlich."
}
# Vendor-Registry: jeder Eintrag muss id + detection mit field/match/pattern haben.
if ($null -ne $S.vendors) {
$allowedFields = @('commandLine','developer','publisher','displayName','notes','owner')
$allowedMatches = @('contains','equals','regex','startsWith')
$seenIds = @{}
foreach ($v in @($S.vendors)) {
if (-not $v.id -or [string]::IsNullOrWhiteSpace($v.id)) { $errs += "Vendor: 'id' erforderlich."; continue }
if ($seenIds.ContainsKey($v.id)) { $errs += "Vendor '$($v.id)': id ist nicht eindeutig."; continue }
$seenIds[$v.id] = $true
if (-not $v.displayName) { $errs += "Vendor '$($v.id)': displayName erforderlich." }
if (-not $v.detection) { $errs += "Vendor '$($v.id)': detection-Block fehlt."; continue }
if ($v.detection.field -notin $allowedFields) { $errs += "Vendor '$($v.id)': detection.field muss eines von $($allowedFields -join '|') sein." }
if ($v.detection.match -notin $allowedMatches) { $errs += "Vendor '$($v.id)': detection.match muss eines von $($allowedMatches -join '|') sein." }
if (-not $v.detection.pattern -or [string]::IsNullOrWhiteSpace($v.detection.pattern)) { $errs += "Vendor '$($v.id)': detection.pattern erforderlich." }
}
}
# Theme-Farben sind Hex-Strings (#RGB oder #RRGGBB)
if ($S.theme -and $S.theme.colors) {
foreach ($key in @('brand','accent','required','success','warning','error','rowSelected','detailPanel')) {
$val = $S.theme.colors.$key
if ($val -and $val -notmatch '^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') {
$errs += "Farbe '$key' ist kein gueltiger Hex-Wert."
}
}
}
return $errs
}
function New-AssignmentItem {
param(
[string]$AppId,
[string]$AppName,
[string]$AppType,
[string]$Type, # "Available" | "Required"
[string]$GroupId,
[string]$GroupName
)
return [pscustomobject]@{
Id = [guid]::NewGuid().ToString()
AppId = $AppId
AppName = $AppName
AppType = $AppType
Type = $Type
GroupId = $GroupId
GroupName = $GroupName
AddedAt = (Get-Date).ToString("o")
}
}
+99
View File
@@ -0,0 +1,99 @@
# HTTP-Router: leitet Requests an statische Dateien oder API-Endpoints weiter.
function Invoke-Router {
param([Parameter(Mandatory=$true)]$Context)
$request = $Context.Request
$path = $request.Url.AbsolutePath.TrimEnd('/')
if ([string]::IsNullOrEmpty($path)) { $path = "/" }
$method = $request.HttpMethod
$isApi = $path.StartsWith("/api/")
if ($isApi) {
Write-Host (">>> [{0}] {1} {2}" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path) -ForegroundColor DarkCyan
}
$reqStart = Get-Date
# API-Endpoints
if ($isApi) {
$body = $null
if ($method -in @("POST","PUT","PATCH")) {
$body = Read-RequestBody -Context $Context
}
$query = @{}
foreach ($key in $request.QueryString.AllKeys) {
if ($null -ne $key) { $query[$key] = $request.QueryString[$key] }
}
try {
$result = Invoke-ApiHandler -Path $path -Method $method -Body $body -Query $query
$handlerMs = [int]((Get-Date) - $reqStart).TotalMilliseconds
if ($null -eq $result) {
Send-JsonResponse -Context $Context -StatusCode 404 -Body @{ error = "Unknown endpoint: $method $path" }
Write-Host ("<<< [{0}] {1} {2} -> 404 ({3}ms)" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path, $handlerMs) -ForegroundColor DarkYellow
} else {
$statusCode = 200
if ($result -is [hashtable] -and $result.ContainsKey('__status')) {
$statusCode = $result['__status']
$result.Remove('__status')
}
Send-JsonResponse -Context $Context -StatusCode $statusCode -Body $result
$totalMs = [int]((Get-Date) - $reqStart).TotalMilliseconds
Write-Host ("<<< [{0}] {1} {2} -> {3} (handler={4}ms total={5}ms)" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path, $statusCode, $handlerMs, $totalMs) -ForegroundColor DarkCyan
}
} catch {
$msg = $_.Exception.Message
# Client-Disconnects (AbortController) sind harmlos und werden nicht geloggt
if ($msg -match "Netzwerkname.*nicht.*verfügbar|connection was forcibly closed|aborted by the software|response has been submitted") {
Write-Host " [client disconnect] $path" -ForegroundColor DarkGray
} else {
Write-Host "<<< [API ERROR] $path -> $msg" -ForegroundColor Red
Write-Host " Stack: $($_.ScriptStackTrace)" -ForegroundColor DarkRed
try {
Send-JsonResponse -Context $Context -StatusCode 500 -Body @{
error = $msg
stack = $_.ScriptStackTrace
}
} catch {}
}
}
return
}
# Assets aus dem Projekt-Root (Logos etc.)
if ($path.StartsWith("/assets/")) {
$name = $path.Substring("/assets/".Length)
$name = $name -replace "[^a-zA-Z0-9._-]", ""
$rootDir = Split-Path -Parent $script:Config.WebRoot
$file = Join-Path $rootDir $name
# Branding-Logos NIE cachen — sonst sieht der User nach dem Upload
# weiter die alte Version, auch wenn die URL gleich bleibt.
$extraHeaders = $null
if ($name -like 'branding-logo.*') {
$extraHeaders = @{ 'Cache-Control' = 'no-store, max-age=0' }
}
Send-FileResponse -Context $Context -FilePath $file -ExtraHeaders $extraHeaders
return
}
# Reports (HTML-Reports werden in Temp gespeichert)
if ($path.StartsWith("/reports/")) {
$name = $path.Substring("/reports/".Length)
$name = $name -replace "[^a-zA-Z0-9._-]", ""
$file = Join-Path $script:Config.ReportDir $name
Send-FileResponse -Context $Context -FilePath $file
return
}
# Statische Dateien aus www/
$relativePath = if ($path -eq "/") { "index.html" } else { $path.TrimStart('/') }
$relativePath = $relativePath -replace '\.\.', '' # path traversal blocken
$file = Join-Path $script:Config.WebRoot $relativePath
if (Test-Path $file -PathType Leaf) {
Send-FileResponse -Context $Context -FilePath $file
} else {
# SPA fallback - alle nicht erkannten Pfade auf index.html
Send-FileResponse -Context $Context -FilePath (Join-Path $script:Config.WebRoot "index.html")
}
}
+159
View File
@@ -0,0 +1,159 @@
# Lokaler HTTP-Server (System.Net.HttpListener)
# Single-threaded request loop - reicht fuer einen Admin-Nutzer.
function Start-WebServer {
param([int]$Port = 8077)
$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)
try {
$listener.Start()
} catch {
Write-Host "Server konnte nicht gestartet werden auf $prefix" -ForegroundColor Red
Write-Host "Moeglicherweise ist der Port belegt oder es fehlen Rechte." -ForegroundColor Red
Write-Host "Tipp: anderen Port mit -Port 8088 versuchen, oder als Admin starten." -ForegroundColor Yellow
Write-Host $_.Exception.Message -ForegroundColor Red
return
}
Write-Host "Server laeuft. Warte auf Requests..." -ForegroundColor Green
Write-Host ""
while ($listener.IsListening) {
try {
$context = $listener.GetContext() # blockiert
try {
Invoke-Router -Context $context
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
try {
Send-JsonResponse -Context $context -StatusCode 500 -Body @{
error = $_.Exception.Message
stack = $_.ScriptStackTrace
}
} catch {}
}
} catch [System.Net.HttpListenerException] {
break
} catch {
Write-Host "[FATAL] $_" -ForegroundColor Red
}
}
$listener.Stop()
$listener.Close()
}
function Send-JsonResponse {
param(
[Parameter(Mandatory=$true)]$Context,
[int]$StatusCode = 200,
$Body = $null
)
$response = $Context.Response
try {
$response.StatusCode = $StatusCode
$response.ContentType = "application/json; charset=utf-8"
$response.Headers.Add("Cache-Control", "no-store")
} catch {
# Headers schon gesendet — Client wahrscheinlich schon weg
return
}
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$json = if ($null -eq $Body) { "{}" } else { $Body | ConvertTo-Json -Depth 12 -Compress }
$sw.Stop()
if ($sw.ElapsedMilliseconds -gt 100) {
Write-Host " [JSON] Serialize $($json.Length) bytes in $($sw.ElapsedMilliseconds)ms (ACHTUNG > 100ms)" -ForegroundColor Yellow
}
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
try {
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.OutputStream.Close()
} catch [System.Net.HttpListenerException], [System.IO.IOException], [System.ObjectDisposedException] {
# Client hat die Verbindung abgebrochen (AbortController, Tab geschlossen, etc.) — irrelevant
} catch {
# Andere Fehler still mitloggen
Write-Host " [WRITE] Response-Schreiben fehlgeschlagen: $($_.Exception.GetType().Name): $($_.Exception.Message)" -ForegroundColor DarkYellow
}
}
function Send-FileResponse {
param(
[Parameter(Mandatory=$true)]$Context,
[Parameter(Mandatory=$true)][string]$FilePath,
[string]$ContentType = $null,
[hashtable]$ExtraHeaders = $null
)
$response = $Context.Response
if (-not (Test-Path $FilePath)) {
$response.StatusCode = 404
$response.OutputStream.Close()
return
}
if (-not $ContentType) {
$ContentType = switch ([IO.Path]::GetExtension($FilePath).ToLower()) {
".html" { "text/html; charset=utf-8" }
".js" { "application/javascript; charset=utf-8" }
".css" { "text/css; charset=utf-8" }
".json" { "application/json; charset=utf-8" }
".svg" { "image/svg+xml" }
".png" { "image/png" }
".jpg" { "image/jpeg" }
".jpeg" { "image/jpeg" }
".webp" { "image/webp" }
".gif" { "image/gif" }
".ico" { "image/x-icon" }
".woff2"{ "font/woff2" }
".md" { "text/markdown; charset=utf-8" }
default { "application/octet-stream" }
}
}
$bytes = [IO.File]::ReadAllBytes($FilePath)
$response.ContentType = $ContentType
# Standard-Cache-Header: HTML/JS/CSS NICHT cachen (single-user Dev-Tool,
# Performance-Verlust irrelevant, dafuer immer aktueller Code im Browser).
# Bilder/Fonts cachen aber muessen — sonst flackert das Logo bei jedem Klick.
$ext = [IO.Path]::GetExtension($FilePath).ToLower()
$cacheCtl = if ($ext -in @('.html','.htm','.js','.css','.json')) {
'no-store, no-cache, must-revalidate, max-age=0'
} else {
'no-cache, must-revalidate'
}
if ($ExtraHeaders -and $ExtraHeaders.ContainsKey('Cache-Control')) {
$cacheCtl = $ExtraHeaders['Cache-Control']
}
$response.Headers.Add('Cache-Control', $cacheCtl)
if ($ExtraHeaders) {
foreach ($k in $ExtraHeaders.Keys) {
if ($k -eq 'Cache-Control') { continue } # bereits gesetzt
try { $response.Headers.Add($k, [string]$ExtraHeaders[$k]) } catch {}
}
}
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.OutputStream.Close()
}
function Read-RequestBody {
param([Parameter(Mandatory=$true)]$Context)
$request = $Context.Request
if (-not $request.HasEntityBody) { return $null }
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
$body = $reader.ReadToEnd()
$reader.Close()
if ([string]::IsNullOrWhiteSpace($body)) { return $null }
# -AsHashtable gibt es erst ab PowerShell 6 — Windows PowerShell 5.1 kennt es nicht.
# Die Endpoints behandeln sowohl Hashtable als auch PSCustomObject korrekt.
if ($PSVersionTable.PSVersion.Major -ge 6) {
return ($body | ConvertFrom-Json -AsHashtable)
}
return ($body | ConvertFrom-Json)
}
+5025
View File
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
# 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.
---
## Schnellstart in 5 Schritten
1. **Verbinden** — oben rechts *Anmelden* klicken → Microsoft-Anmeldefenster bestätigen.
2. **Tab wählen** — Topbar: *App Management* (Standard), *Group Management* oder *App Report*.
3. **Empfänger wählen** (App Management) — linke Spalte: Abteilung, RPA oder Benutzer markieren.
4. **Zuweisung sammeln** — bei einer App auf **Available** oder **Required** klicken.
5. **Ausführen** — rechts unten *Zuweisungen ausführen*. HTML-Report öffnet sich automatisch.
---
## Navigation
Der Header enthält drei Tabs:
| Tab | Funktion |
|---|---|
| **App Management** | Haupt-Workflow: Apps laden, Gruppen zuweisen und entfernen |
| **Group Management** | Mitglieder anzeigen, exportieren, hinzufügen, CSV-Import |
| **App Report** | Installationszähler pro App, Geräte-Export als CSV |
> Im **Read-Only-Modus** (Fallback auf eingeschränkte App-ID) sind die Tabs *Benutzer hinzufügen* und *CSV-Import* ausgeblendet. Alle Schreib-Buttons verschwinden automatisch.
---
## App Management — der Hauptworkflow
### Das mentale Modell
Das Tool ändert **nicht die App-Zuweisungen selbst** — die Liste der Available/Required-Gruppen einer App bleibt unverändert. Stattdessen ändert es die **Mitgliedschaften in genau diesen Gruppen**. Beispiel:
- App *Beispiel-App* hat die Required-Gruppe `intune-win-app-<slug>-required` zugewiesen.
- Du wählst links die Abteilung `<dept-prefix>-team-controlling`.
- Klick auf die *Required*-Pille → Operation wandert in die Session.
- Beim Ausführen wird `<dept-prefix>-team-controlling` als Mitglied in der Required-Gruppe aufgenommen.
- Intune zieht beim nächsten Sync alle Mitglieder als Required-Targets.
### Schritt-für-Schritt
1. **Empfänger markieren** (linke Spalte) — Modi mischbar: Abteilungen + RPA + Benutzer gleichzeitig. Die *Pinned-Targets*-Leiste zeigt alles Aktive.
2. **Apps durchgehen** (mittlere Spalte) — Filter oder Suchfeld nutzen.
3. **Available- oder Required-Button klicken**:
- *Eine Gruppe*: Empfänger direkt zur Session hinzufügen.
- *Mehrere Gruppen* (Count-Badge): Picker öffnet sich.
- *Keine Gruppe* dieses Typs: Button deaktiviert.
4. **Session prüfen** (rechte Spalte) — alle gesammelten Vorgänge als Vorschau.
5. **Ausführen** — rechts unten. Report öffnet automatisch.
### Pillen-Farben
| Optik | Bedeutung |
|---|---|
| Neutral | Standard — kein Empfänger gewählt oder noch nicht geprüft |
| **Grüner Rahmen + Häkchen** | Alle Empfänger bereits Mitglied |
| **Gelber Rahmen + halbes Häkchen** | Teilweise Mitglied |
| **Schwarz gefüllt + Count-Badge** | Aktiv in der Session (Toggle: erneuter Klick entfernt) |
| Kursiv, ausgegraut | Nur native Zuweisung (All Users/Devices) — keine Gruppe änderbar |
### Gruppen aus App entfernen (−R / −A)
Jede App-Zeile zeigt rechts **−R** (Required entfernen) und **−A** (Available entfernen).
- Klick auf −R / −A → Picker mit allen Gruppen dieses Typs → Gruppe auswählen → sofortiges Löschen der Zuweisung (nach Bestätigung).
- Bei nativen Zuweisungen (All Users/All Devices) erscheint stattdessen der Mülleimer-Icon direkt im Detail-Panel.
### Plus-Button — Zuweisung hinzufügen
Modal mit 6 Optionen:
**REQUIRED** — Neue Gruppe / All Users / All Devices
**AVAILABLE** — Neue Gruppe / All Users / All Devices
Bereits aktive native Kombinationen werden **ausgegraut** (verhindert Duplikate).
### Die Session (rechte Spalte)
Pro Eintrag: App-Name + Gruppen-Name + Empfänger-Tag + A/R-Badge + X-Entfernen.
Darunter Gesamtzahl — der Apply-Button zeigt diese Zahl.
### Ausführen und Report
- Member-Add pro Session-Eintrag
- Native Targets werden übersprungen
- Bereits-Mitglied = „OK" — kein Fehler
- HTML-Report in `%TEMP%\IntuneAppManager-Reports\`
---
## Group Management
### Mitglieder & Export
- Gruppe links suchen und auswählen
- Mitgliederliste inkl. verschachtelter Gruppen
- Filter zum Einschränken der Anzeige
- **Als CSV exportieren** → `members-<Gruppe>-<Datum>.csv`
### Benutzer hinzufügen *(nur Read/Write)*
- Benutzer per Name, UPN oder E-Mail suchen
- Mehrere auswählen und gemeinsam zur gewählten Gruppe hinzufügen
### CSV-Import *(nur Read/Write)*
Massenimport von Benutzern per CSV-Datei oder direkter Eingabe.
**Ablauf:**
1. Gruppe links auswählen
2. CSV hochladen **oder** UPNs/E-Mails einfügen (eine pro Zeile)
3. **„Benutzer auflösen"** → Graph-Lookup → Vorschau mit ✓/✗
4. **„Gefundene importieren"** → Benutzer werden zur Gruppe hinzugefügt
**CSV-Format:** Header-Erkennung für `UserPrincipalName`, `Mail`, `Email`, `UPN`.
Ohne Header: erste Spalte mit `@` wird verwendet. Quoted fields und `,`/`;`/Tab als Trennzeichen werden unterstützt.
**Ergebnis-Banner nach Import:**
| Chip | Bedeutung |
|---|---|
| ✔ Grün | Erfolgreich hinzugefügt |
| ≈ Gelb | Bereits Mitglied |
| ✗ Rot | Fehler (Meldung in der Tabelle) |
| Grau | UPN in Graph nicht gefunden |
---
## App Report
- **Neu laden** — lädt Installationszähler für alle Apps (kann 1–2 Min. dauern)
- Tabelle sortierbar per Klick auf Spaltenheader (▲/▼)
- Suchfeld filtert nach Name, Publisher, Typ
- **Als CSV exportieren** — exportiert die gefilterte Tabelle
- **Geräte-Export** (Button pro Zeile) — lädt alle Geräte mit Installationsstatus für diese App als CSV
> Zählwerte erfordern die Berechtigung `DeviceManagementManagedDevices.Read.All` in der Azure App-Registration. Fehlt sie, zeigen alle Spalten 0.
---
## Header
| Element | Funktion |
|---|---|
| **Logo** | Kann in den Einstellungen durch eigenes Firmenlogo ersetzt werden |
| **App Management / Group Management / App Report** | Haupt-Navigation |
| **Einstellungen** (Zahnrad) | Tenant/Client-ID, Naming, Farben, Logo |
| **Hilfe** (Fragezeichen) | Diese Seite |
| **Theme-Toggle** | Light/Dark-Mode — wird im LocalStorage gemerkt |
| **Avatar** | Angemeldeter Account — Mouseover zeigt vollen UPN; grüner Punkt = verbunden |
| **Logout** | Trennt die Graph-Verbindung |
---
## Read-Only-Modus
Ist nur eine Read-Only-App-ID konfiguriert (oder schlägt die RW-Anmeldung fehl), schaltet das Tool automatisch in den **Read-Only-Modus**:
- Toast-Meldung „(Read Only)" beim Verbinden
- Alle Schreib-Buttons/Tabs werden ausgeblendet (`body.read-only`-CSS-Klasse)
- Sichtbar bleibt: App-Liste, Mitglieder-Ansicht, Export-Funktionen, App Report
---
## Linke Spalte — Empfänger
Drei Modi via Segment-Switch:
- **Abteilung** — Gruppen mit konfigurierten Präfixen (mehrere möglich)
- **RPA** — explizit in den Einstellungen gelistete Gruppen
- **Benutzer** — Live-Suche über Graph (min. 2 Zeichen)
**Gruppen aufklappen** → Mitglieder inline → *Benutzer übernehmen* in den User-Modus.
**Icons pro Eintrag:**
| Icon | Wirkung |
|---|---|
| Externer Link | Gruppe/User im Intune-Portal öffnen |
| Zwei Personen | Mitglieder-Ansicht im Intune-Portal |
| Teams-T | Direkt-Chat starten (Teams-Desktop) |
---
## Mittlere Spalte — Apps
### Filter
| Filter | Zweck |
|---|---|
| Suchfeld | Volltext im App-Namen |
| Dropdown | Preset: Alle / Ohne Zuweisungen / Nur Available / Nur Required / Mehrere / All Users / All Devices / Intune / pro Vendor |
| Bereits Gruppenmitglied | Nur Apps wo Empfänger schon Mitglied ist |
| All Users/Devices ausblenden | Native Zuweisungen rausnehmen |
| Geändert (Datumsrange) | Nach Last-Modified-Datum filtern |
### App-Zeile
1. Status-Indikator (Häkchen)
2. App-Name + Hersteller → Klick öffnet Detail-Panel
3. Rename-Icon (Stift) / Lock bei Vendor-Apps
4. Source-Logo (Intune, PatchMyPC, Robopack, …)
5. Typ-Chip
6. Version
7. **Available**- und **Required**-Buttons mit Count
8. **−R** (Required-Gruppe entfernen) und **−A** (Available-Gruppe entfernen)
9. **Plus-Button** — Zuweisung hinzufügen
10. **Delete-Icon** — App löschen (bei Vendor-Apps: Lock)
---
## App-Detail (Klick auf App-Namen)
| Sektion | Inhalt |
|---|---|
| Zuweisungen | Available + Required mit Aktionen (Link, Mitglieder, Löschen) |
| Allgemein | Hersteller, Version, Typ, Min-OS |
| Beschreibung | Markdown-gerendert |
| Installation | Datei, Install-/Uninstall-Befehl (Copy-Icon) |
| Detection | Registry, Datei, MSI-Code, PowerShell |
| Return-Codes | Exit-Codes mit Typ |
| Meta | Erstellt + Geändert |
| Installationen | Geräte + Benutzer × Installiert/Fehler/Pending/N/A mit Erfolgsquote |
| Abhängigkeiten | SVG-Flow-Diagramm + Klartext; Klick auf Knoten springt zur Ziel-App |
| Supersedence | Was diese App ersetzt (update / replace) |
---
## Einstellungen
Persistiert in `%APPDATA%\IntuneAppManager-Web\settings.json`.
| Sektion | Inhalt |
|---|---|
| Verbindung | Tenant-ID, Client-ID (RW), Client-ID (RO), Scopes RW + RO |
| Abteilungs-Gruppen | Präfixe (eine pro Zeile) |
| RPA-Gruppen | Explizite Gruppennamen |
| Benutzer-Suche | Felder (DisplayName / UPN / Mail / Department) |
| Required-Naming | Präfix + Suffix |
| Available-Naming | Präfix + Suffix |
| Branding | Logo-Upload (PNG/JPG/SVG/WEBP/GIF, max. 2 MB) |
| Farben | 8 Akzentfarben + Standardfarben wiederherstellen |
---
## Benötigte Graph-Berechtigungen
| Funktion | Berechtigung |
|---|---|
| Apps anzeigen | `DeviceManagementApps.Read.All` |
| Gruppen / Mitglieder anzeigen | `GroupMember.Read.All` |
| Benutzer suchen | `User.Read.All` |
| Zuweisungen ändern | `DeviceManagementApps.ReadWrite.All` |
| Gruppen-Mitglieder ändern | `GroupMember.ReadWrite.All` |
| App Report / Geräte-Export | `DeviceManagementManagedDevices.Read.All` ⚠ |
> ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App Report 0 und der Geräte-Export liefert leere Dateien.
---
## Wichtige Einschränkungen
- **Vendor-Apps** (PatchMyPC, Robopack, …) können nicht gelöscht/umbenannt werden — Lock-Icon + HTTP 409.
- **Native Zuweisungen** (All Users / All Devices) sind je App und Intent einzigartig — Duplikate werden ausgegraut.
- **Required-Zuweisungen** werden beim nächsten Geräte-Sync erzwungen — bei Produktiv-Apps zuerst Available testen.
- **App-Löschung** ist irreversibel und entfernt alle Zuweisungen.
- **Cache**: Apps und Gruppen werden im PowerShell-Prozess gehalten — Refresh-Icon lädt neu.
---
## Vendor-Erkennung
Das Tool erkennt Vendor-Apps (PatchMyPC, Robopack, …) per konfigurierbaren Detection-Regeln in `settings.json → vendors[]`:
| Feld | Optionen |
|---|---|
| `field` | `commandLine`, `developer`, `publisher`, `displayName`, `notes`, `owner` |
| `match` | `contains`, `equals`, `startsWith`, `regex` |
| `pattern` | Suchstring oder Regex |
Erster Match gewinnt. Eigene Vendoren: neuen Eintrag im `vendors[]`-Array, Logo als `<id>.png` im Projekt-Root ablegen.
---
## Setup-Datei aktualisieren
Native Win32-Apps (`.intunewin`) können mit einer neuen Setup-Datei aktualisiert werden ohne die App neu anzulegen.
**Wo:** App-Detail → Abschnitt *Setup-Datei* → *Setup-Datei aktualisieren…*
**Ablauf:** lokalen Pfad auf dem Server-Rechner eingeben → optionale neue Versionsnummer → Bestätigung → *Hochladen & aktivieren*
> **Achtung:** Ersetzt die ausgerollte Version für alle zugewiesenen Geräte. Nicht rückgängig machbar — zuerst an einer Test-App ausprobieren.
---
## Tastatur / Tipps
- **Ctrl+F5** — Hard-Refresh (Pflicht nach Tool-Updates)
- **Esc** — Modal schließen
- **Enter** im Umbenennen-Dialog — speichern
- **Klick auf App-Zeile** außerhalb Buttons — Detail öffnen/schließen
---
## Fehlerbehebung
| Symptom | Lösung |
|---|---|
| „User canceled authentication" | WAM-Fenster ist hinter anderen Fenstern — in Taskleiste suchen |
| Apps laden sehr lange | Bei > 1000 Apps normal (10–30 s); danach gecached |
| Änderungen wirken nicht | **Ctrl+F5** (Browser-Cache) |
| App Report zeigt nur 0 | `DeviceManagementManagedDevices.Read.All` fehlt in der App-Registration |
| Geräte-Export ist leer | Gleiche Ursache wie App Report |
| Read-Only-Modus obwohl RW gewünscht | RW-Anmeldung fehlgeschlagen — Berechtigungen in Azure prüfen |
| Port-Konflikt beim Start | `.\Start.ps1 -Port 8088` |
| PowerShell-Errors beim Start | `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force` |
| Apply-Button bleibt grau | 0 Vorgänge in der Session oder nur native Targets |
---
Stand: Juli 2026 · Hilfe-Datei: `www/help.md` · Änderungshistorie: `CHANGELOG.md`
+994
View File
@@ -0,0 +1,994 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune Manager</title>
<script>
// Theme vor erstem Render anwenden — verhindert Flash
(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) { /* localStorage-Block */ }
})();
</script>
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/vendor/flatpickr.min.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>
<!-- HEADER -->
<header class="topbar">
<div class="brand">
<div id="brandLogo" class="logo"><span class="logo-letter">I</span></div>
<div class="brand-text">
<div class="brand-name">Intune Manager</div>
</div>
</div>
<nav class="topbar-nav" id="topbarNav" aria-label="Hauptnavigation">
<button class="nav-tab active" data-view="apps" aria-selected="true">App Management</button>
<button class="nav-tab" data-view="groups" aria-selected="false">Group Management</button>
<button class="nav-tab" data-view="report" aria-selected="false">App Report</button>
</nav>
<div class="header-actions">
<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>
<button id="btnHelp" class="icon-btn icon-btn-header" title="Hilfe" aria-label="Hilfe ö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="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<button id="btnThemeToggle" class="theme-toggle" role="switch" aria-checked="false" title="Dark/Light Mode umschalten" aria-label="Dark/Light Mode umschalten">
<span class="theme-track">
<svg class="theme-ico theme-ico-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="theme-ico theme-ico-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span class="theme-knob"></span>
</span>
</button>
<div id="connStatus" class="conn-pill conn-off" title="Verbindungsstatus">
<span class="dot"></span>
<span class="conn-text">Offline</span>
</div>
<button id="btnDisconnect" class="icon-btn icon-btn-header hidden" title="Trennen" aria-label="Verbindung trennen">
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</header>
<!-- ONBOARDING (MSAL.js Browser-Popup) -->
<div id="onboarding" class="onboarding hidden">
<div class="onboarding-card">
<!-- View 1: idle -->
<div data-onboarding-view="idle">
<div class="onboarding-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M12 2 L4 6 v6 c0 5 3.5 9 8 10 c4.5-1 8-5 8-10 V6 z"/>
<path d="M9 12 l2 2 l4-4"/>
</svg>
</div>
<h1 class="onboarding-title">Mit Microsoft anmelden</h1>
<p class="onboarding-text">
Es öffnet sich ein Microsoft-Anmeldefenster. Wähle den gewünschten Account aus.
</p>
<div id="onboardingSetupHint" class="banner-warning hidden">
Konfiguration unvollständig — bitte zuerst <a href="#" id="onboardingOpenSettings">Einstellungen</a> öffnen und Tenant ID, Client ID und Abteilungs-Präfix setzen.
</div>
<button id="btnConnect" class="btn btn-primary btn-lg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M11.4 2.4H2.4v9h9v-9zm10.2 0h-9v9h9v-9zm-10.2 10.2H2.4v9h9v-9zm10.2 0h-9v9h9v-9z"/></svg>
Anmeldung starten
</button>
<div class="onboarding-meta">
Lese- und Zuweisungs-Rechte für Intune-Apps und Gruppenmitgliedschaften.
</div>
</div>
<!-- View 2: waiting -->
<div data-onboarding-view="awaiting" class="hidden">
<div class="dc-spinner-wrap">
<div class="spinner"></div>
</div>
<h2 class="onboarding-title">Anmeldung läuft</h2>
<p class="onboarding-text">
Bitte im Microsoft-Anmeldefenster fortfahren. Wir holen es automatisch in den Vordergrund —
falls es trotzdem im Hintergrund liegt, schau in der Taskleiste.
</p>
<button id="btnCancelConnect" class="btn btn-text">Abbrechen</button>
</div>
<!-- View 3: error -->
<div data-onboarding-view="error" class="hidden">
<div class="onboarding-icon onboarding-icon-error">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
</div>
<h2 class="onboarding-title">Anmeldung fehlgeschlagen</h2>
<p class="onboarding-text" id="dcError">Unbekannter Fehler.</p>
<button id="btnRetryConnect" class="btn btn-primary btn-lg">Erneut versuchen</button>
</div>
</div>
</div>
<!-- MAIN -->
<main class="layout" id="layout" data-view="apps">
<!-- LEFT: TARGETS -->
<section class="panel panel-targets">
<div class="panel-head">
<div class="panel-title">
<h2>Abteilungen &amp; Benutzer</h2>
<span id="targetCount" class="count-badge">0</span>
</div>
<button id="btnReloadTargets" 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 class="mode-segment" role="tablist">
<button class="seg active" data-mode="dept" role="tab">Abteilung</button>
<button class="seg" data-mode="rpa" role="tab">RPA</button>
<button class="seg" data-mode="user" role="tab">Benutzer</button>
</div>
<div class="search-wrap">
<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="targetSearch" placeholder="Filtern..." class="input input-search" autocomplete="off">
<span id="userSearchHint" class="search-hint hidden">min. 2 Zeichen</span>
</div>
<div class="select-toolbar">
<div class="select-actions">
<button id="btnTargetsSelectAll" class="link-btn">Alle</button>
<span class="dotsep">·</span>
<button id="btnTargetsClear" class="link-btn">Leeren</button>
</div>
</div>
<div id="pinnedTargets" class="pinned-targets hidden" aria-label="Aktuelle Auswahl">
<!-- populated by JS -->
</div>
<div id="targetList" class="list-area target-list">
<!-- populated by JS -->
</div>
</section>
<!-- CENTER: APPS -->
<section class="panel panel-apps">
<div class="panel-head">
<div class="panel-title">
<h2>Apps</h2>
<span id="appCount" class="count-badge">0</span>
</div>
<div class="panel-head-actions">
<button id="btnReloadApps" class="icon-btn" title="Neu laden (Cache umgehen)" 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>
<div class="apps-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="appSearch" placeholder="Apps durchsuchen..." class="input input-search" autocomplete="off">
</div>
<select id="appFilter" class="select">
<option value="all">Alle Apps</option>
<option value="none">Ohne Zuweisungen</option>
<option value="available">Nur Available</option>
<option value="required">Nur Required</option>
<option value="multipleAvail">Mehrere Available</option>
<option value="multipleReq">Mehrere Required</option>
<option value="allUsers">All Users</option>
<option value="allDevices">All Devices</option>
<option value="srcIntune">Nur Intune-Apps</option>
<!-- Vendor-Optionen werden dynamisch von rebuildVendorFilterOptions() ergaenzt -->
</select>
<select id="appCategoryFilter" class="select" title="Nach App-Kategorie filtern">
<option value="">Alle Kategorien</option>
<!-- Kategorie-Optionen werden dynamisch von rebuildCategoryFilterOptions() befuellt -->
</select>
</div>
<div class="filter-chips">
<label class="check-chip">
<input type="checkbox" id="chkOnlyMember">
<span>Bereits Gruppenmitglied</span>
</label>
<label class="check-chip">
<input type="checkbox" id="chkHideNative">
<span>All Users/Devices ausblenden</span>
</label>
<div class="date-filter-chip" id="dateFilterChip">
<span class="date-filter-label" title="Nur Apps anzeigen die im angegebenen Zeitraum geändert wurden">Geändert</span>
<input type="date" id="dateModFrom" class="date-input" title="Von (inkl.)">
<span class="date-sep">–</span>
<input type="date" id="dateModTo" class="date-input" title="Bis (inkl.)">
<button type="button" id="dateModClear" class="date-clear" hidden title="Datum-Filter zurücksetzen">×</button>
</div>
<div class="legend-group">
<span class="legend-item" title="Alle ausgewählten Empfänger sind bereits Mitglied"><span class="row-status full">✓</span> bereits zugewiesen</span>
<span class="legend-item" title="Teilweise Mitglied"><span class="row-status partial">◐</span> teilweise</span>
</div>
</div>
<div id="appList" class="list-area apps-list">
<!-- populated by JS -->
</div>
</section>
<!-- RIGHT: SESSION -->
<aside class="panel panel-side">
<div class="panel-head">
<div class="panel-title">
<h2>Geplante Zuweisungen</h2>
<span id="sessionCount" class="count-badge accent">0</span>
</div>
</div>
<div class="side-pane" id="sidePaneSession">
<div id="sessionList" class="session-list">
<!-- populated by JS -->
</div>
<div class="session-summary">
<div class="summary-section">
<div class="summary-section-title">Ausgewählte Empfänger</div>
<div class="summary-row sub">
<span class="muted">Abteilungsgruppen</span>
<span id="sumGroupsSel">0</span>
</div>
<div class="summary-row sub">
<span class="muted">Einzelbenutzer</span>
<span id="sumUsersSel">0</span>
</div>
</div>
<div class="summary-section">
<div class="summary-section-title">Zuweisungen erhalten</div>
<div class="summary-row sub">
<span class="muted">Abteilungsgruppen</span>
<span id="sumGroupsOps">0</span>
</div>
<div class="summary-row sub">
<span class="muted">Einzelbenutzer</span>
<span id="sumUsersOps">0</span>
</div>
</div>
<div class="summary-row total">
<span>Gesamt-Vorgänge</span>
<span id="sumTotal">0</span>
</div>
</div>
<button id="btnApply" class="btn btn-apply" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M5 12l5 5L20 7"/></svg>
<span id="btnApplyLabel">Zuweisungen ausführen</span>
</button>
<button id="btnSessionClear" class="link-btn link-btn-danger" hidden>Geplante Zuweisungen leeren</button>
</div>
<div class="side-pane hidden" id="sidePaneExisting">
<div class="side-pane-hint">Apps, in denen die ausgewählten Empfänger bereits Mitglied einer zugewiesenen Gruppe sind.</div>
<div id="existingList" class="existing-list">
<!-- populated by JS -->
</div>
</div>
</aside>
</main>
<!-- GROUP MANAGEMENT VIEW -->
<section class="group-mgmt-view hidden" id="groupMgmtView">
<div class="gex-body group-mgmt-body">
<!-- Tab-Leiste -->
<div class="gex-tab-bar group-mgmt-tabs">
<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>
</div>
<div class="gex-columns group-mgmt-columns">
<!-- Linke Spalte: Gruppensuche -->
<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: Export-Tab -->
<div class="gex-right" id="gexPaneExport">
<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 class="group-mgmt-foot">
<span id="gexStats" class="muted"></span>
<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>
<!-- Rechte Spalte: CSV-Import -->
<div class="gex-right hidden" id="gexPaneImport">
<div class="gex-section-title">UPNs einfügen oder CSV hochladen</div>
<div class="csv-import-input-row">
<textarea id="csvImportText" class="csv-import-textarea" placeholder="Eine UPN/E-Mail pro Zeile, z.B.&#10;max.mustermann@firma.de&#10;erika.muster@firma.de&#10;&#10;Oder erste Spalte einer CSV (Header wird erkannt)."></textarea>
<label class="csv-upload-label" title="CSV-Datei auswählen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
CSV laden
<input type="file" id="csvImportFile" accept=".csv,.txt" style="display:none">
</label>
</div>
<div class="csv-import-actions">
<button class="btn btn-secondary btn-sm" id="csvResolveBtn">Benutzer auflösen</button>
<span id="csvResolveStats" class="muted" style="font-size:12px;"></span>
</div>
<!-- Vorschau-Tabelle -->
<div id="csvPreviewWrap" class="csv-preview-wrap hidden">
<div class="gex-section-title" style="margin-top:10px;">
Vorschau
<span id="csvFoundBadge" class="count-badge" style="background:var(--success,#4ade80);color:#000;margin-left:6px;">0 gefunden</span>
<span id="csvNotFoundBadge" class="count-badge" style="background:var(--error,#f87171);color:#fff;margin-left:4px;">0 nicht gefunden</span>
</div>
<div class="csv-preview-table-wrap">
<table class="csv-preview-table">
<thead><tr><th></th><th>UPN / E-Mail</th><th>Name</th><th>Status</th></tr></thead>
<tbody id="csvPreviewBody"></tbody>
</table>
</div>
</div>
<!-- Ergebnis-Banner (nach Import) -->
<div id="csvResultBanner" class="csv-result-banner hidden">
<div class="csv-result-stats">
<span id="csvResAdded" class="csv-res-chip csv-res-ok">0 hinzugefügt</span>
<span id="csvResExist" class="csv-res-chip csv-res-warn">0 bereits Mitglied</span>
<span id="csvResError" class="csv-res-chip csv-res-err">0 Fehler</span>
<span id="csvResSkipped" class="csv-res-chip csv-res-dim">0 nicht gefunden</span>
</div>
</div>
<div class="group-mgmt-foot">
<span class="muted" id="csvImportNote" style="font-size:11px;">Gruppe links auswählen, dann Benutzer auflösen.</span>
<button class="btn btn-primary" id="csvImportBtn" 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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
Gefundene importieren
</button>
</div>
</div>
<!-- Rechte Spalte: Benutzer hinzufügen -->
<div class="gex-right hidden" id="gexPaneAdd">
<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="gexUserSearch" class="input input-search" placeholder="Name, UPN oder E-Mail…" autocomplete="off">
</div>
<div id="gexUserResults" class="gex-results gex-add-results">
<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>
</div>
<div class="gex-section-title" style="margin-top:12px;">
Ausgewählt <span id="gexSelectedCount" class="count-badge">0</span>
</div>
<div id="gexSelectedUsers" class="gex-selected-area">
<div class="gex-hint">Noch keine Benutzer ausgewählt.</div>
</div>
<div class="group-mgmt-foot">
<button class="btn btn-primary" id="gexAddBtn" 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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
Zu Gruppe hinzufügen
</button>
</div>
</div>
</div>
</div>
</section>
<!-- APP REPORT VIEW -->
<section class="app-report-view hidden" id="appReportView">
<div class="app-report-toolbar">
<div class="search-wrap">
<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="reportSearch" class="input input-search" placeholder="App suchen…" autocomplete="off">
</div>
<span id="reportCount" class="count-badge">0</span>
<button class="btn btn-secondary btn-sm" id="btnReportRefresh" title="Daten neu laden">
<svg width="13" height="13" 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>
Neu laden
</button>
<button class="btn btn-primary btn-sm" id="btnReportExport" disabled>
<svg width="13" height="13" 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 class="app-report-table-wrap">
<table class="app-report-table" id="reportTable">
<thead>
<tr>
<th class="sortable" data-col="AppName">App-Name <span class="sort-ico"></span></th>
<th class="sortable" data-col="Publisher">Publisher <span class="sort-ico"></span></th>
<th class="sortable" data-col="AppType">Typ <span class="sort-ico"></span></th>
<th class="sortable" data-col="Version">Version <span class="sort-ico"></span></th>
<th class="sortable num" data-col="InstalledDeviceCount">Installiert <span class="sort-ico"></span></th>
<th class="sortable num" data-col="PendingInstallDeviceCount">Ausstehend <span class="sort-ico"></span></th>
<th class="sortable num" data-col="FailedDeviceCount">Fehlgeschlagen <span class="sort-ico"></span></th>
<th class="sortable num" data-col="NotApplicableDeviceCount">Nicht anwendbar <span class="sort-ico"></span></th>
<th class="report-th-export" title="Gerätestatus-Export"></th>
</tr>
</thead>
<tbody id="reportBody">
<tr><td colspan="9" class="report-hint">Klicke auf „Neu laden" um Daten zu laden.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- TOAST CONTAINER -->
<div id="toasts" class="toasts"></div>
<!-- LOADING OVERLAY -->
<div id="loading" class="loading hidden">
<div class="loading-card">
<div class="spinner"></div>
<div id="loadingText" class="loading-text">Bitte warten...</div>
</div>
</div>
<!-- MODAL: Create Required Group -->
<div id="modalCreateGroup" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Zuweisung hinzufügen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="lbl" for="cgAppName">App</label>
<input type="text" id="cgAppName" class="input" readonly>
</div>
<div class="form-group">
<label class="lbl">Typ der Zuweisung</label>
<div class="cg-intent-grid">
<div class="cg-intent-col cg-intent-required">
<div class="cg-intent-head">
<span class="cg-intent-badge req">REQUIRED</span>
<span class="cg-intent-sub">App wird zwingend installiert</span>
</div>
<label class="cg-mode">
<input type="radio" name="cgMode" value="req-group" checked>
<span class="cg-mode-body">
<span class="cg-mode-title">Neue Gruppe</span>
<span class="cg-mode-desc">Neue Required-Gruppe nach Naming-Schema erstellen und zuweisen.</span>
</span>
</label>
<label class="cg-mode">
<input type="radio" name="cgMode" value="req-allusers">
<span class="cg-mode-body">
<span class="cg-mode-title">All Users</span>
<span class="cg-mode-desc">Native Required-Zuweisung an alle lizenzierten Benutzer.</span>
</span>
</label>
<label class="cg-mode">
<input type="radio" name="cgMode" value="req-alldevices">
<span class="cg-mode-body">
<span class="cg-mode-title">All Devices</span>
<span class="cg-mode-desc">Native Required-Zuweisung an alle Geräte des Tenants.</span>
</span>
</label>
</div>
<div class="cg-intent-col cg-intent-available">
<div class="cg-intent-head">
<span class="cg-intent-badge avail">AVAILABLE</span>
<span class="cg-intent-sub">Selbstinstallation via Firmenportal</span>
</div>
<label class="cg-mode">
<input type="radio" name="cgMode" value="avail-group">
<span class="cg-mode-body">
<span class="cg-mode-title">Neue Gruppe</span>
<span class="cg-mode-desc">Neue Available-Gruppe nach Naming-Schema erstellen und zuweisen.</span>
</span>
</label>
<label class="cg-mode">
<input type="radio" name="cgMode" value="avail-allusers">
<span class="cg-mode-body">
<span class="cg-mode-title">All Users</span>
<span class="cg-mode-desc">App erscheint im Firmenportal aller Benutzer.</span>
</span>
</label>
<label class="cg-mode">
<input type="radio" name="cgMode" value="avail-alldevices">
<span class="cg-mode-body">
<span class="cg-mode-title">All Devices</span>
<span class="cg-mode-desc">App ist auf allen Geräten verfügbar.</span>
</span>
</label>
</div>
</div>
</div>
<div class="form-group" id="cgGroupNameGroup">
<label class="lbl" for="cgGroupName">Gruppen-Name (Vorschlag — kann angepasst werden)</label>
<input type="text" id="cgGroupName" class="input">
<div id="cgStatus" class="form-hint"></div>
</div>
<label class="check-chip" id="cgAssignToAppWrap">
<input type="checkbox" id="cgAssignToApp" checked>
<span>Gruppe direkt der App zuweisen</span>
</label>
</div>
<div class="modal-foot">
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="cgCreate">Erstellen</button>
</div>
</div>
</div>
<!-- MODAL: Apply Result -->
<div id="modalResult" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3 id="resHeadline">Ergebnis</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div id="resBanner" class="res-banner"></div>
<div class="result-stats">
<div class="rstat ok"><div class="num" id="resOk">0</div><div class="lbl">Erfolgreich</div></div>
<div class="rstat warn"><div class="num" id="resSkip">0</div><div class="lbl">Übersprungen</div></div>
<div class="rstat err"><div class="num" id="resErr">0</div><div class="lbl">Fehler</div></div>
<div class="rstat tot"><div class="num" id="resTotal">0</div><div class="lbl">Gesamt</div></div>
</div>
<div id="resDetails" class="res-details"></div>
<div id="resReportLink" class="report-link"></div>
<div id="resDebug" class="res-debug"></div>
</div>
<div class="modal-foot">
<button class="btn btn-secondary" data-close>Schließen</button>
</div>
</div>
</div>
<!-- MODAL: Mitglieder einer Gruppe einsehen / einzelne User uebernehmen -->
<div id="modalMembers" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3 id="mmTitle">Mitglieder</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="search-wrap" style="margin: 0 0 12px 0;">
<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="mmSearch" class="input input-search" placeholder="Mitglieder filtern...">
</div>
<div class="mm-toolbar">
<button class="link-btn" id="mmSelectAll">Alle</button>
<span class="dotsep">·</span>
<button class="link-btn" id="mmClear">Leeren</button>
</div>
<div id="mmList" class="member-list"></div>
</div>
<div class="modal-foot">
<span id="mmInfo" class="muted" style="margin-right: auto;">0 ausgewählt</span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="mmApply">Als Benutzer-Auswahl übernehmen</button>
</div>
</div>
</div>
<!-- MODAL: Confirm Apply -->
<div id="modalConfirm" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box">
<div class="modal-head">
<h3>Zuweisungen ausführen?</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div id="confirmText" class="confirm-text"></div>
<div class="confirm-warn">Hinzufügen ist sofort wirksam — Mitgliedschaften können später manuell entfernt werden.</div>
</div>
<div class="modal-foot">
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-apply" id="btnConfirmApply">Ja, ausführen</button>
</div>
</div>
</div>
<!-- MODAL: Hilfe -->
<div id="modalHelp" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-xl">
<div class="modal-head">
<h3>Hilfe &amp; Funktionsverzeichnis</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body help-body">
<aside class="help-toc" id="helpToc">
<div class="help-toc-title">Inhalt</div>
<ul></ul>
</aside>
<div class="help-content md-content" id="helpContent">
<div class="help-loading">Hilfe wird geladen…</div>
</div>
</div>
<div class="modal-foot">
<span id="helpVersion" class="help-version-badge"></span>
<span class="modal-foot-spacer"></span>
<button class="btn btn-primary" data-close>Schließen</button>
</div>
</div>
</div>
<!-- MODAL: App umbenennen -->
<div id="modalRenameApp" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box">
<div class="modal-head">
<h3>App umbenennen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="lbl">Aktueller Name</label>
<div id="raOldName" class="ra-old-name"></div>
</div>
<div class="form-group">
<label class="lbl" for="raNewName">Neuer Name</label>
<input type="text" id="raNewName" class="input" maxlength="256" autocomplete="off" spellcheck="false">
<div id="raError" class="form-hint form-hint-err"></div>
</div>
<div class="form-hint">Wirkt sofort in Intune. Vendor-verwaltete Apps (PatchMyPC, Robopack, …) können nicht hier umbenannt werden.</div>
</div>
<div class="modal-foot">
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="raSubmit">Umbenennen</button>
</div>
</div>
</div>
<!-- MODAL: Setup-Datei aktualisieren -->
<div id="modalUpdateContent" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box">
<div class="modal-head">
<h3>Setup-Datei aktualisieren</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="lbl">App</label>
<div id="ucAppName" class="ra-old-name"></div>
</div>
<div class="banner-warning">
<strong>Achtung:</strong> Dies lädt eine neue Setup-Datei hoch und aktiviert sie als neue Version der App. Alle zugewiesenen Geräte erhalten die neue Version beim nächsten Intune-Sync. Vorgang ist nicht rückgängig zu machen.
</div>
<div class="form-group">
<label class="lbl" for="ucFilePath">Pfad zur neuen Datei (lokal auf dem Server-Rechner)</label>
<div class="uc-path-row">
<input type="text" id="ucFilePath" class="input mono" spellcheck="false" autocomplete="off" placeholder="C:\Pakete\app-2.0\app.intunewin">
<button type="button" class="btn btn-secondary" id="ucBrowse">Durchsuchen…</button>
</div>
<div class="form-hint" id="ucFileHint">Phase 1: nur <code>.intunewin</code> (win32LobApp). Der Server liest die Datei direkt von diesem Pfad. <em>Durchsuchen</em> öffnet den Datei-Dialog auf dem Rechner, auf dem das Tool läuft.</div>
</div>
<div class="form-group">
<label class="lbl" for="ucVersion">Neue Versionsnummer (optional)</label>
<input type="text" id="ucVersion" class="input mono" spellcheck="false" autocomplete="off" placeholder="z.B. 2.0.1">
</div>
<label class="check-chip"><input type="checkbox" id="ucConfirm"><span>Ich weiß, dass die ausgerollte Version ersetzt wird</span></label>
<div id="ucError" class="form-hint form-hint-err"></div>
<div id="ucProgress" class="content-update-progress hidden"></div>
</div>
<div class="modal-foot">
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="ucSubmit" disabled>Hochladen &amp; aktivieren</button>
</div>
</div>
</div>
<!-- MODAL: Einstellungen -->
<div id="modalSettings" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Einstellungen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div id="settingsForm" class="settings-form">
<div id="setupBanner" class="banner-warning hidden">
<strong>Erste Konfiguration noetig.</strong>
Bitte mindestens Tenant ID, Client ID und Abteilungs-Praefix setzen. Danach <em>Verbindung + Lookups testen</em> ganz unten, dann speichern.
</div>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Microsoft Graph Verbindung</h4>
<div class="settings-sec-sub">Tenant- und App-Registrierung. Änderungen erzwingen einen Disconnect.</div>
</div>
<div class="form-group">
<label class="lbl" for="setTenantId">Tenant ID (GUID)</label>
<input type="text" id="setTenantId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">Findest du im Entra Admin Center → Übersicht → Tenant-Informationen.</div>
</div>
<div class="form-group">
<label class="lbl" for="setClientId">Client ID – Read/Write (GUID)</label>
<input type="text" id="setClientId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">App-Registrierung mit Schreibrechten. Erforderlich: <em>Allow public client flows</em> = Ja, Redirect URI <code>http://localhost</code>.</div>
</div>
<div class="form-group">
<label class="lbl" for="setClientIdRo">Client ID – Read Only (GUID, optional)</label>
<input type="text" id="setClientIdRo" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
<div class="form-hint">Separate App-Registrierung ohne Schreibrechte. Nutzer dieser App sehen alle Daten, können aber nichts ändern. Leer lassen wenn nicht benötigt.</div>
</div>
<div class="form-group">
<label class="lbl" for="setScopes">Scopes Read/Write (eine pro Zeile)</label>
<textarea id="setScopes" class="input mono" rows="4" spellcheck="false" placeholder="Group.ReadWrite.All&#10;GroupMember.ReadWrite.All&#10;User.Read.All&#10;DeviceManagementApps.ReadWrite.All"></textarea>
<div class="form-hint">Die Standard-Liste passt für den vollen Funktionsumfang.</div>
</div>
<div class="form-group">
<label class="lbl" for="setScopesRo">Scopes Read Only (eine pro Zeile)</label>
<textarea id="setScopesRo" class="input mono" rows="4" spellcheck="false" placeholder="Group.Read.All&#10;GroupMember.Read.All&#10;User.Read.All&#10;DeviceManagementApps.Read.All"></textarea>
<div class="form-hint">Scopes für die Read-Only-App-Registrierung.</div>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Abteilungs-Gruppen</h4>
<div class="settings-sec-sub">Ein oder mehrere Präfixe für den Abteilungs-Modus (linke Spalte). Alle Gruppen, deren <code>displayName</code> mit einem dieser Präfixe beginnt, werden geladen.</div>
</div>
<div class="form-group">
<label class="lbl" for="setDeptPrefixes">Präfixe (eine pro Zeile)</label>
<textarea id="setDeptPrefixes" class="input mono" rows="3" spellcheck="false" placeholder="abt-hm&#10;abt-extern"></textarea>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>RPA-Gruppen</h4>
<div class="settings-sec-sub">Genaue <code>displayName</code>-Werte der Gruppen, die im RPA-Tab angezeigt werden. Eine pro Zeile.</div>
</div>
<div class="form-group">
<label class="lbl" for="setRpaGroups">RPA-Gruppennamen</label>
<textarea id="setRpaGroups" class="input mono" rows="4" spellcheck="false" placeholder="intune-userrole-windowsclientuser-rpa&#10;intune-userrole-windowsclientuser-rpa-fbt&#10;intune-userrole-windowsclientuser-rpa-pro"></textarea>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Benutzer-Suche</h4>
<div class="settings-sec-sub">In welchen Feldern bei der Benutzersuche gesucht wird (alle markierten werden mit <code>OR</code> verknüpft).</div>
</div>
<div class="form-group form-check-grid">
<label class="check-chip"><input type="checkbox" id="setUsfDisplayName"><span>Display Name</span></label>
<label class="check-chip"><input type="checkbox" id="setUsfUpn"><span>UPN</span></label>
<label class="check-chip"><input type="checkbox" id="setUsfMail"><span>E-Mail</span></label>
<label class="check-chip"><input type="checkbox" id="setUsfDepartment"><span>Department</span></label>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Required-Gruppen-Naming</h4>
<div class="settings-sec-sub">Schema für neue Required-Gruppen aus dem App-Manager. Format: <code>{Präfix}{App-Slug}{Suffix}</code>.</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="lbl" for="setReqPrefix">Präfix</label>
<input type="text" id="setReqPrefix" class="input mono" placeholder="intune-win-app-">
</div>
<div class="form-group">
<label class="lbl" for="setReqSuffix">Suffix</label>
<input type="text" id="setReqSuffix" class="input mono" placeholder="-required">
</div>
</div>
<div class="form-hint">Vorschau: <code id="setReqPreview" class="set-preview"></code></div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Available-Gruppen-Naming</h4>
<div class="settings-sec-sub">Schema für neue Available-Gruppen aus dem App-Manager. Format: <code>{Präfix}{App-Slug}{Suffix}</code>.</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="lbl" for="setAvailPrefix">Präfix</label>
<input type="text" id="setAvailPrefix" class="input mono" placeholder="intune-win-app-">
</div>
<div class="form-group">
<label class="lbl" for="setAvailSuffix">Suffix</label>
<input type="text" id="setAvailSuffix" class="input mono" placeholder="-available">
</div>
</div>
<div class="form-hint">Vorschau: <code id="setAvailPreview" class="set-preview"></code></div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Branding — Logo</h4>
<div class="settings-sec-sub">Eigenes Logo links oben im Header. Wird im Projekt-Verzeichnis als <code>branding-logo.&lt;ext&gt;</code> gespeichert. Max. 2 MB.</div>
</div>
<div class="logo-row">
<div class="logo-preview" id="logoPreview">
<span class="logo-letter">I</span>
</div>
<div class="logo-actions">
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif" class="hidden">
<button type="button" class="btn btn-secondary btn-sm" id="btnLogoPick">Bild auswählen…</button>
<button type="button" class="btn btn-text link-btn-danger" id="btnLogoRemove" hidden>Entfernen</button>
<div class="form-hint" id="logoHint">PNG, JPG, SVG, WEBP, GIF</div>
</div>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head settings-sec-head-row">
<div>
<h4>Farben</h4>
<div class="settings-sec-sub">Akzentfarben für Highlights. Soft/Hover/Background-Varianten werden automatisch abgeleitet.</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="btnRestoreColors" title="Alle Farben auf die Firmen-Standardwerte zurücksetzen">↺ Standardfarben wiederherstellen</button>
</div>
<div class="color-grid">
<div class="color-row" data-color-key="brand">
<span class="color-swatch" data-swatch="brand"></span>
<div class="color-meta">
<div class="color-name">Brand (Firmenfarbe)</div>
<div class="color-desc">Logo-Hintergrund, Stepper-Indikator</div>
</div>
<input type="color" id="setColBrand" class="color-picker">
<input type="text" id="setColBrandHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="brand" title="Auf Standard zurücksetzen">↺</button>
</div>
<div class="color-row" data-color-key="accent">
<span class="color-swatch" data-swatch="accent"></span>
<div class="color-meta">
<div class="color-name">Available</div>
<div class="color-desc">Available-Buttons, Links, Akzent-Hover</div>
</div>
<input type="color" id="setColAccent" class="color-picker">
<input type="text" id="setColAccentHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="accent" title="Auf Standard zurücksetzen">↺</button>
</div>
<div class="color-row" data-color-key="required">
<span class="color-swatch" data-swatch="required"></span>
<div class="color-meta">
<div class="color-name">Required</div>
<div class="color-desc">Required-Buttons und -Badges</div>
</div>
<input type="color" id="setColRequired" class="color-picker">
<input type="text" id="setColRequiredHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="required">↺</button>
</div>
<div class="color-row" data-color-key="success">
<span class="color-swatch" data-swatch="success"></span>
<div class="color-meta">
<div class="color-name">Erfolg / Bereits zugewiesen</div>
<div class="color-desc">Member-Indikatoren, Erfolgs-Toasts</div>
</div>
<input type="color" id="setColSuccess" class="color-picker">
<input type="text" id="setColSuccessHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="success">↺</button>
</div>
<div class="color-row" data-color-key="warning">
<span class="color-swatch" data-swatch="warning"></span>
<div class="color-meta">
<div class="color-name">Warnung / Teilweise</div>
<div class="color-desc">Partial-Member, Skip-Statuses</div>
</div>
<input type="color" id="setColWarning" class="color-picker">
<input type="text" id="setColWarningHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="warning">↺</button>
</div>
<div class="color-row" data-color-key="error">
<span class="color-swatch" data-swatch="error"></span>
<div class="color-meta">
<div class="color-name">Fehler</div>
<div class="color-desc">Fehler-Toasts, Delete-Hover, Validation</div>
</div>
<input type="color" id="setColError" class="color-picker">
<input type="text" id="setColErrorHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="error">↺</button>
</div>
<div class="color-row" data-color-key="rowSelected">
<span class="color-swatch" data-swatch="rowSelected"></span>
<div class="color-meta">
<div class="color-name">Markierte App-Zeile</div>
<div class="color-desc">Hintergrund der aufgeklappten App-Zeile + linker Akzentstreifen. Schrift/Icons passen sich automatisch dem Kontrast an.</div>
</div>
<input type="color" id="setColRowSelected" class="color-picker">
<input type="text" id="setColRowSelectedHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="rowSelected">↺</button>
</div>
<div class="color-row" data-color-key="detailPanel">
<span class="color-swatch" data-swatch="detailPanel"></span>
<div class="color-meta">
<div class="color-name">Details-Bereich</div>
<div class="color-desc">Hintergrund des aufgeklappten Detail-Blocks unter der App. Text-Farbe wird automatisch nach Lesbarkeit gewählt.</div>
</div>
<input type="color" id="setColDetailPanel" class="color-picker">
<input type="text" id="setColDetailPanelHex" class="input mono color-hex" maxlength="7" spellcheck="false">
<button type="button" class="link-btn" data-color-reset="detailPanel">↺</button>
</div>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Konfiguration pruefen</h4>
<div class="settings-sec-sub">Prueft die aktuell eingetragenen Werte gegen Microsoft Graph: Abteilungs-Gruppen-Lookup, RPA-Gruppen und Benutzer-Suche. Voraussetzung: Verbindung steht.</div>
</div>
<div class="form-row">
<button type="button" class="btn btn-secondary" id="btnSettingsTest">Verbindung + Lookups testen</button>
<div id="settingsTestResult" class="settings-test-result"></div>
</div>
</section>
<section class="settings-sec settings-sec-meta">
<div class="settings-sec-head">
<h4>Datei</h4>
</div>
<div class="form-hint">Gespeichert in: <code id="setFilePath" class="set-path">…</code></div>
</section>
</div>
</div>
<div class="modal-foot">
<button class="btn btn-text link-btn-danger" id="btnSettingsReset" type="button">Auf Standardwerte zurücksetzen</button>
<span class="modal-foot-spacer"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="btnSettingsSave">Speichern</button>
</div>
</div>
</div>
<!-- MODAL: Gruppen-Export (leeres Stub — Inhalt jetzt in #groupMgmtView) -->
<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-Verwaltung</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body"></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. -->
<script src="/vendor/marked.min.js"></script>
<!-- Date-Picker (bundled, MIT, flatpickr v4) — ersetzt den nativen
date-input Popup, vermeidet Browser-Quirks (schwarzes Initial-Render). -->
<script src="/vendor/flatpickr.min.js"></script>
<script src="/app.js"></script>
</body>
</html>
+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>
+5159
View File
File diff suppressed because it is too large Load Diff
+13
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+69
View File
File diff suppressed because one or more lines are too long