web app mit hinzugefügt

This commit is contained in:
2026-06-17 07:40:52 +02:00
parent fedd8898d7
commit 2f271265e9
24 changed files with 14651 additions and 11 deletions
+234
View File
@@ -0,0 +1,234 @@
# 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.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
+115
View File
@@ -0,0 +1,115 @@
# Intune App-Manager – Web Edition
Modernes Web-Frontend fuer den Intune App-Zuweisungs-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
cd "c:\Temp\claude.code\Intune App Management\Intune-App-Manager-Web"
.\Start.ps1
```
Optionen:
| Parameter | Standard | Beschreibung |
|---------------|-----------------------------------------|---------------------------------------------|
| `-Port` | `8077` | Port des lokalen HTTP-Servers |
| `-TenantId` | (HanseMerkur Default) | Microsoft Entra Tenant-ID |
| `-ClientId` | (HanseMerkur Default) | App-Registrierung Client-ID |
| `-NoBrowser` | aus | Browser nicht automatisch oeffnen |
Beispiele:
```powershell
.\Start.ps1 -Port 8088
.\Start.ps1 -TenantId "..." -ClientId "..."
.\Start.ps1 -NoBrowser
```
Beenden: `Ctrl+C` im Terminal.
## Workflow
1. **Verbinden** – oben rechts klicken; Microsoft Login-Fenster oeffnet sich.
2. **Modus waehlen**: Abteilungen / RPA-Gruppen / Einzel-User.
3. **Ziele laden** – ueber "Laden" (User: in Suchfeld tippen).
4. **Ziele markieren** mit Checkboxen.
5. **Apps laden** – ueber "Laden" rechts neben "Apps".
6. **Pillen klicken** – jede gruene Pille bei einer App ist eine zugewiesene
Gruppe. Klick = "Ausgewaehlte Ziele dieser Gruppe hinzufuegen". Erneuter
Klick = entfernen.
7. **Required-Gruppe erstellen** – falls noetig, ueber "+ Required-Gruppe"
bei der App.
8. **Session pruefen** – rechts werden alle geplanten Operationen aufgelistet.
9. **Ausfuehren** – gruener Button rechts unten. HTML-Report erscheint
automatisch nach Abschluss.
## Pillen-Farben (App-Zeile)
| Farbe | Bedeutung |
|-------------|-------------------------------------------------------------------|
| Grau | Gruppe ist der App zugewiesen, Ziele sind keine Mitglieder |
| Gruen | Alle ausgewaehlten Ziele sind bereits Mitglied |
| Gelb | Einige Ziele sind Mitglied (Partial) |
| Cyan | Native Target (All Users / All Devices) – nicht aenderbar |
| Blau | Aktuell zur Session hinzugefuegt |
| Gestrichelt | "+ Required-Gruppe" Button |
## Architektur
```
Intune-App-Manager-Web/
├── Start.ps1 Entry Point
├── README.md diese Datei
├── src/
│ ├── Models.ps1 Hilfen / Datenklassen
│ ├── 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 Modernes Dark Theme
└── app.js Frontend-Logik (Vanilla JS)
```
Reports werden in `%TEMP%\IntuneAppManager-Reports\` gespeichert und sind
ueber `http://localhost:<port>/reports/<datei>.html` erreichbar.
## REST-API (intern)
| Methode | Pfad | Zweck |
|---------|----------------------------------------------|------------------------------------|
| GET | `/api/status` | Verbindungsstatus |
| POST | `/api/connect` | Microsoft Graph verbinden |
| POST | `/api/disconnect` | Trennen |
| GET | `/api/groups` | Abteilungs-Gruppen (`abt-hm-*`) |
| GET | `/api/groups/rpa` | 3 vordefinierte RPA-Gruppen |
| POST | `/api/groups` | Neue Required-Gruppe erstellen |
| POST | `/api/groups/check` | Gruppen-Name pruefen |
| GET | `/api/groups/{id}/members` | Mitglieder einer Gruppe |
| GET | `/api/users?q=...` | User suchen |
| GET | `/api/apps` | Apps laden (cached) |
| GET | `/api/apps?refresh=true` | Apps neu laden |
| GET | `/api/membership?targets=...&groupId=...` | Mitgliedschafts-Status pruefen |
| POST | `/api/assignments/apply` | Geplante Zuweisungen ausfuehren |
## Hinweise
- **Single-User-App** – der lokale HTTP-Server nimmt nur eine Verbindung
zur Zeit zuverlaessig an. Den Browser-Tab bitte einzeln halten.
- **Auth lebt im PowerShell-Prozess** – Disconnect oder Schliessen des
Terminals beendet die Session.
- **Cache** – Apps werden im Server-Prozess gecached (Refresh-Knopf zum
Neuladen). Membership-Cache wird bei Aenderung der Ziel-Auswahl geleert.
+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 App-Manager" /min powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%~dp0Start.ps1" %*
+221
View File
@@ -0,0 +1,221 @@
# Intune App-Zuweisungs-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
Scopes = @($script:Settings.connection.scopes)
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
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 App-Zuweisungs-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
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because it is too large Load Diff
+923
View File
@@ -0,0 +1,923 @@
# Microsoft Graph API Helpers
# Verwendet ausschliesslich Microsoft.Graph.Authentication + Invoke-MgGraphRequest,
# um Versionskonflikte zu vermeiden.
function Initialize-GraphModule {
if (Get-Module Microsoft.Graph.Authentication) { return $true }
try {
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop -MinimumVersion 2.0.0
$loaded = Get-Module Microsoft.Graph.Authentication
Write-Host "[GRAPH] Microsoft.Graph.Authentication v$($loaded.Version) geladen" -ForegroundColor DarkGray
return $true
} catch {
$available = @(Get-Module Microsoft.Graph.Authentication -ListAvailable)
if ($available.Count -eq 0) {
throw "Microsoft.Graph.Authentication ist nicht installiert. Bitte ausfuehren: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force"
}
$maxVer = ($available | Sort-Object Version -Descending | Select-Object -First 1).Version
if ($maxVer -lt [version]"2.0.0") {
throw "Microsoft.Graph.Authentication v$maxVer ist zu alt. Mindestens v2.0 noetig. Bitte: Update-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force"
}
throw "Microsoft.Graph.Authentication konnte nicht geladen werden: $($_.Exception.Message)"
}
}
function Invoke-MgGraphRequestRetry {
# Duenner Wrapper um Invoke-MgGraphRequest mit Retry-Backoff fuer 429/503.
# Microsoft-Best-Practice: Throttling (429) immer abfangen und mit
# Retry-After-Verzoegerung erneut versuchen. 503 analog (Service busy).
# Andere Fehler werden unveraendert durchgereicht (kein Verschlucken).
param(
[Parameter(Mandatory=$true)][string]$Uri,
[string]$Method = 'GET',
$Body,
[string]$ContentType,
[hashtable]$Headers,
[int]$MaxRetries = 3
)
$attempt = 0
while ($true) {
try {
$params = @{ Uri = $Uri; Method = $Method }
if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params.Body = $Body }
if ($ContentType) { $params.ContentType = $ContentType }
if ($Headers) { $params.Headers = $Headers }
return Invoke-MgGraphRequest @params
} catch {
$msg = $_.Exception.Message
$is429 = $msg -match '\b429\b|Too many requests|throttl'
$is503 = $msg -match '\b503\b|Service Unavailable'
if (($is429 -or $is503) -and $attempt -lt $MaxRetries) {
$attempt++
# Retry-After aus dem Fehler ziehen wenn vorhanden, sonst Backoff 2/5/10s.
$wait = $null
if ($msg -match 'Retry-After[:\s]+(\d+)') { $wait = [int]$matches[1] }
if (-not $wait -or $wait -le 0) { $wait = @(2, 5, 10)[[Math]::Min($attempt - 1, 2)] }
$code = if ($is429) { '429' } else { '503' }
Write-Host " [THROTTLE] $code -> Retry $attempt/$MaxRetries in ${wait}s" -ForegroundColor DarkYellow
Start-Sleep -Seconds $wait
continue
}
throw
}
}
}
function Get-GraphPaged {
param([Parameter(Mandatory=$true)][string]$Uri)
$results = @()
$next = $Uri
do {
$response = Invoke-MgGraphRequestRetry -Uri $next -Method GET
if ($response.value) { $results += $response.value }
$next = $response.'@odata.nextLink'
} while ($next)
return $results
}
function Get-GraphGroupByFilter {
param(
[string]$Filter,
[string[]]$Property = @("id","displayName"),
[switch]$ExpandMembers
)
$select = "`$select=" + ($Property -join ",").ToLower()
$uri = "https://graph.microsoft.com/v1.0/groups?$select"
if ($Filter) { $uri += "&`$filter=$([uri]::EscapeDataString($Filter))" }
# $expand=members($select=id) liefert bis zu 20 Mitglieder-IDs pro Gruppe —
# genug, um "leer ja/nein" zuverlaessig zu erkennen (Graph cap ist 20).
if ($ExpandMembers) { $uri += "&`$expand=members(`$select=id)" }
return Get-GraphPaged -Uri $uri
}
function Get-GraphGroupById {
param([string]$Id, [string[]]$Property = @("id","displayName"))
$select = "`$select=" + ($Property -join ",").ToLower()
return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$Id`?$select" -Method GET
}
function Get-GraphGroupMembers {
param([string]$GroupId)
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName"
}
function Get-GraphGroupMembersTransitive {
param([string]$GroupId)
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName"
}
function Search-GraphUser {
param([string]$SearchTerm)
$term = $SearchTerm -replace "'", "''"
$select = "id,displayName,userPrincipalName,mail,department"
# Welche Felder durchsucht werden ist konfigurierbar (Settings).
$allowed = @('displayName','userPrincipalName','mail','department')
$cfgFields = @($script:Settings.userSearch.fields | Where-Object { $_ -in $allowed })
if ($cfgFields.Count -eq 0) { $cfgFields = @('displayName','userPrincipalName','mail') }
# 1) startswith — schnell, deckt Praefix-Tippen ab (90%+ aller Suchen)
try {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$parts = @($cfgFields | ForEach-Object { "startswith($_,'$term')" })
$filter = $parts -join " or "
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25"
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
$items = @()
if ($resp -and $resp.value) { $items = @($resp.value) }
$sw.Stop()
Write-Host " -> User-Search '$SearchTerm' [$($cfgFields -join ',')]: startswith=$($items.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray
if ($items.Count -gt 0) { return $items }
} catch {
Write-Host " -> startswith fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
}
# 2) Fallback: $search (substring) nur wenn startswith leer war
try {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$searchTerm = $SearchTerm -replace '"', ''
$parts = @($cfgFields | ForEach-Object { "`"$_`:$searchTerm`"" })
$searchExpr = $parts -join " OR "
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$top=25&`$search=$([uri]::EscapeDataString($searchExpr))"
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" }
$items = @()
if ($resp -and $resp.value) { $items = @($resp.value) }
$sw.Stop()
Write-Host " -> User-Search '$SearchTerm': `$search-Fallback=$($items.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray
return $items
} catch {
Write-Host " -> `$search-Fallback fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
return @()
}
}
function Resolve-GroupNamesBulk {
# Loest viele Gruppen-IDs in einem Schwung via directoryObjects/getByIds auf
# und schreibt {Id => DisplayName} in die uebergebene Hashtable.
param(
[Parameter(Mandatory=$true)][object[]]$Ids,
[Parameter(Mandatory=$true)][hashtable]$Lookup
)
if (-not $Ids -or $Ids.Count -eq 0) { return }
# WICHTIG: zu plain strings casten — sonst wickelt ConvertTo-Json (intern in
# Invoke-MgGraphRequest) PSObject-gewrappte Strings in .Chars und crasht mit
# "Self referencing loop". Genau das hat den Batch unbrauchbar gemacht.
$cleanIds = [string[]]@($Ids | ForEach-Object { [string]$_ } | Where-Object { $_ })
$batchSize = 800 # API-Limit ist 1000, Puffer fuer Safety
for ($i = 0; $i -lt $cleanIds.Count; $i += $batchSize) {
$end = [Math]::Min($i + $batchSize - 1, $cleanIds.Count - 1)
$chunk = [string[]]$cleanIds[$i..$end]
try {
# JSON manuell serialisieren — vermeidet jeden Wrapper-Spass
$bodyJson = @{
ids = $chunk
types = @("group")
} | ConvertTo-Json -Depth 3 -Compress
$resp = Invoke-MgGraphRequest `
-Uri "https://graph.microsoft.com/v1.0/directoryObjects/getByIds" `
-Method POST `
-Body $bodyJson `
-ContentType "application/json"
if ($resp.value) {
foreach ($obj in $resp.value) {
if ($obj.id -and $obj.displayName) {
$Lookup[[string]$obj.id] = [string]$obj.displayName
}
}
}
} catch {
Write-Host " -> getByIds-Batch fehlgeschlagen ($($chunk.Count) IDs): $($_.Exception.Message)" -ForegroundColor DarkYellow
# Fallback nur fuer den fehlgeschlagenen Batch — und auch nur wenn die ID
# noch nicht gesetzt wurde
foreach ($id in $chunk) {
if ($Lookup.ContainsKey($id)) { continue }
try {
$g = Get-GraphGroupById -Id $id -Property @("id","displayName")
if ($g.displayName) { $Lookup[$id] = [string]$g.displayName }
} catch {
$Lookup[$id] = "($id)"
}
}
}
}
}
function New-GraphSecurityGroup {
param([string]$DisplayName, [string]$MailNickname)
$body = @{
displayName = $DisplayName
mailEnabled = $false
securityEnabled = $true
mailNickname = $MailNickname
}
return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups" -Method POST -Body $body
}
function Add-GraphMember {
param([string]$GroupId, [string]$DirectoryObjectId)
# JSON manuell bauen — vermeidet PSObject-Wrapper-Probleme bei der Serialisierung
$bodyJson = '{"@odata.id":"https://graph.microsoft.com/v1.0/directoryObjects/' + $DirectoryObjectId + '"}'
# Out-Null suppressed das $null das Invoke-MgGraphRequest zurueckgibt — sonst
# landet es in der Pipeline des Aufrufers und korrumpiert die Response.
# Ueber den Retry-Wrapper: Member-Adds sind die haeufigste Bulk-Write-Operation
# (Session-Apply mit vielen Empfaengern) und damit am throttle-anfaelligsten.
$null = Invoke-MgGraphRequestRetry `
-Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" `
-Method POST `
-Body $bodyJson `
-ContentType "application/json"
}
function Get-GraphMobileApps {
param([switch]$WithAssignments)
# WICHTIG: NUR assignments expandieren. categories zusaetzlich auf der
# Vollliste zu expandieren liess Graph hart drosseln (429 -> "Too many
# retries"). Kategorien werden separat + throttle-sicher per $batch
# nachgeladen (Get-GraphMobileAppCategoriesBatch).
$expand = if ($WithAssignments) { "?`$expand=assignments" } else { "" }
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps$expand"
return Get-GraphPaged -Uri $uri
}
function Get-GraphMobileAppCategoriesBatch {
# Liefert eine Map appId -> @(KategorieNamen) fuer die uebergebenen App-IDs
# ueber Microsoft Graph $batch (20 Sub-Requests pro Batch — API-Limit).
# Throttle-sicher: schlaegt ein Batch fehl, werden dessen Apps einfach
# ohne Kategorien gefuehrt — die App-Liste funktioniert unabhaengig davon.
param([string[]]$AppIds)
$map = @{}
if (-not $AppIds -or $AppIds.Count -eq 0) { return $map }
$chunkSize = 20
for ($i = 0; $i -lt $AppIds.Count; $i += $chunkSize) {
$end = [Math]::Min($i + $chunkSize - 1, $AppIds.Count - 1)
$slice = $AppIds[$i..$end]
$requests = @()
foreach ($id in $slice) {
$requests += @{ id = $id; method = 'GET'; url = "/deviceAppManagement/mobileApps/$id/categories" }
}
$body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress
try {
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
foreach ($r in @($resp.responses)) {
$rid = [string]$r.id
$st = [int]$r.status
if ($st -eq 200 -and $r.body -and $r.body.value) {
$names = @()
foreach ($cat in @($r.body.value)) {
$n = $null
try { if ($cat.displayName) { $n = [string]$cat.displayName } } catch {}
if (-not $n -and $cat -is [System.Collections.IDictionary] -and $cat['displayName']) { $n = [string]$cat['displayName'] }
if ($n) { $names += $n }
}
$map[$rid] = $names
}
}
} catch {
Write-Host " [CATS-BATCH] Batch $i-$end fehlgeschlagen (uebersprungen): $($_.Exception.Message)" -ForegroundColor DarkYellow
}
}
return $map
}
function Get-GraphMobileAppDetails {
param([Parameter(Mandatory=$true)][string]$AppId)
# Liefert das vollstaendige App-Objekt inkl. typspezifischer Felder
# (Win32: installCommandLine etc., MSI: productCode, Store: licenseType, ...).
return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId" -Method GET
}
function Add-GraphAppAssignment {
param(
[string]$AppId,
[string]$Intent, # "available" | "required"
[string]$GroupId # GUID oder "ALL_USERS" / "ALL_DEVICES"
)
$target = switch ($GroupId) {
"ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } }
"ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } }
default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = $GroupId } }
}
$body = @{
mobileAppAssignments = @(@{
"@odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = $Intent
target = $target
settings = $null
})
}
Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body
}
function Get-GraphMobileAppAssignments {
param([Parameter(Mandatory=$true)][string]$AppId)
$resp = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assignments" -Method GET
if ($resp.value) { return @($resp.value) }
return @()
}
function Remove-GraphAppAssignmentByGroup {
# Loescht jede Zuweisung der App, deren Target zur uebergebenen Group passt.
# Optional kann ein Intent gesetzt werden, dann werden nur Zuweisungen mit
# genau diesem Intent (required/available) entfernt.
param(
[Parameter(Mandatory=$true)][string]$AppId,
[Parameter(Mandatory=$true)][string]$GroupId,
[string]$Intent
)
$assignments = Get-GraphMobileAppAssignments -AppId $AppId
$deleted = @()
foreach ($a in $assignments) {
$tgt = $a.target
if (-not $tgt) { continue }
$tgtType = [string]$tgt.'@odata.type'
$match = $false
switch ($GroupId) {
"ALL_USERS" { if ($tgtType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { $match = $true } }
"ALL_DEVICES" { if ($tgtType -eq '#microsoft.graph.allDevicesAssignmentTarget') { $match = $true } }
default {
if ($tgtType -eq '#microsoft.graph.groupAssignmentTarget' -and [string]$tgt.groupId -eq $GroupId) { $match = $true }
}
}
if (-not $match) { continue }
if ($Intent -and ([string]$a.intent) -ne $Intent) { continue }
$null = Invoke-MgGraphRequest `
-Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assignments/$($a.id)" `
-Method DELETE
$deleted += [pscustomobject]@{
AssignmentId = [string]$a.id
Intent = [string]$a.intent
TargetType = $tgtType
}
}
return $deleted
}
function Update-GraphMobileApp {
# PATCH auf eine MobileApp. Wichtig: '@odata.type' MUSS im Body sein, sonst
# gibt Graph "ResourceNotSupported" / 400 zurueck (polymorpher Typ).
# Versucht beta, faellt bei 400/404/405 auf v1.0 zurueck.
param(
[Parameter(Mandatory=$true)][string]$AppId,
[Parameter(Mandatory=$true)][string]$AppTypeRaw, # z.B. '#microsoft.graph.win32LobApp'
[Parameter(Mandatory=$true)][hashtable]$Patch # z.B. @{ displayName = 'neuer Name' }
)
$body = [ordered]@{}
$body['@odata.type'] = $AppTypeRaw
foreach ($k in $Patch.Keys) { $body[$k] = $Patch[$k] }
$json = $body | ConvertTo-Json -Depth 5 -Compress
$endpoints = @(
"https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId",
"https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$AppId"
)
$lastErr = $null
foreach ($uri in $endpoints) {
try {
$null = Invoke-MgGraphRequest -Uri $uri -Method PATCH -Body $json -ContentType "application/json"
return
} catch {
$lastErr = $_
$msg = "$($_.Exception.Message)"
try { $msg += " " + $_.ErrorDetails.Message } catch {}
if ($msg -match '404|400|405|MethodNotAllowed|NotSupported') { continue }
throw
}
}
if ($lastErr) {
$body = $null
try { $body = $lastErr.ErrorDetails.Message } catch {}
$detail = if ($body) { " | Graph-Response: $body" } else { "" }
throw [System.Exception]::new("PATCH fehlgeschlagen: $($lastErr.Exception.Message)$detail")
}
}
# ============================================================
# Content-Update: neue Setup-Datei in eine native App hochladen
# (Phase 1: .intunewin / win32LobApp — vorverschluesselt)
# ============================================================
function Read-IntuneWinPackage {
# Entpackt eine .intunewin (ZIP) und liest die bereits verschluesselte
# Innen-Datei + die EncryptionInfo aus Metadata/Detection.xml. Es wird
# NICHT neu verschluesselt — IntuneWinAppUtil hat das schon getan.
param([Parameter(Mandatory=$true)][string]$Path)
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
$archive = [System.IO.Compression.ZipFile]::OpenRead($Path)
try {
$detEntry = $archive.Entries | Where-Object { $_.FullName -match 'IntuneWinPackage/Metadata/Detection\.xml$' } | Select-Object -First 1
if (-not $detEntry) { throw "Detection.xml nicht im .intunewin gefunden — ist die Datei wirklich ein IntuneWinAppUtil-Paket?" }
# Detection.xml lesen
$sr = New-Object System.IO.StreamReader($detEntry.Open())
$xmlText = $sr.ReadToEnd(); $sr.Close()
[xml]$xml = $xmlText
$info = $xml.ApplicationInfo
$enc = $info.EncryptionInfo
if (-not $enc) { throw "EncryptionInfo fehlt in Detection.xml" }
$fileName = [string]$info.FileName # innerer Dateiname (z.B. setup.intunewin)
$unencSize = [int64]$info.UnencryptedContentSize
# Innere, bereits verschluesselte Datei extrahieren
$contentEntry = $archive.Entries | Where-Object { $_.FullName -match "IntuneWinPackage/Contents/.+" } | Select-Object -First 1
if (-not $contentEntry) { throw "Verschluesselte Innen-Datei (Contents/) nicht gefunden" }
$ms = New-Object System.IO.MemoryStream
$cs = $contentEntry.Open()
$cs.CopyTo($ms); $cs.Close()
$encryptedBytes = $ms.ToArray(); $ms.Dispose()
return @{
EncryptedBytes = $encryptedBytes
Size = $unencSize
SizeEncrypted = [int64]$encryptedBytes.Length
FileName = $fileName
EncryptionInfo = @{
'@odata.type' = '#microsoft.graph.fileEncryptionInfo'
encryptionKey = [string]$enc.EncryptionKey
macKey = [string]$enc.MacKey
initializationVector = [string]$enc.InitializationVector
mac = [string]$enc.Mac
profileIdentifier = [string]$enc.ProfileIdentifier
fileDigest = [string]$enc.FileDigest
fileDigestAlgorithm = [string]$enc.FileDigestAlgorithm
}
}
} finally {
$archive.Dispose()
}
}
function New-GraphAppContentVersion {
# Schritt 1: neue contentVersion anlegen. GraphType z.B. 'win32LobApp'.
param([string]$AppId, [string]$GraphType)
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions"
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body '{}' -ContentType 'application/json'
$cvId = if ($resp.id) { [string]$resp.id } else { [string]$resp.Id }
if (-not $cvId) { throw "contentVersion-Anlage lieferte keine id zurueck" }
return $cvId
}
function New-GraphAppContentFile {
# Schritt 2: File-Eintrag in der contentVersion anlegen (mit Klartext-Size +
# verschluesselter Size). Liefert fileId.
param(
[string]$AppId, [string]$GraphType, [string]$CvId,
[string]$Name, [int64]$Size, [int64]$SizeEncrypted, $Manifest = $null
)
$body = [ordered]@{
'@odata.type' = '#microsoft.graph.mobileAppContentFile'
name = $Name
size = $Size
sizeEncrypted = $SizeEncrypted
isDependency = $false
}
if ($Manifest) { $body['manifest'] = $Manifest } # base64 (MSI/MSIX); win32 = weglassen
$json = $body | ConvertTo-Json -Depth 5 -Compress
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files"
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json'
$fileId = if ($resp.id) { [string]$resp.id } else { [string]$resp.Id }
if (-not $fileId) { throw "File-Anlage lieferte keine id zurueck" }
return $fileId
}
function Get-GraphAppContentFile {
param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId)
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId"
return Invoke-MgGraphRequestRetry -Uri $uri -Method GET
}
function Wait-GraphContentFileState {
# Schritt 3/6: pollt den File-Eintrag bis TargetState erreicht ist.
# Bricht bei *Failed-Zustaenden und Timeout ab.
param(
[string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId,
[string]$TargetState, [int]$TimeoutSec = 180
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ($true) {
$f = Get-GraphAppContentFile -AppId $AppId -GraphType $GraphType -CvId $CvId -FileId $FileId
$state = if ($f.uploadState) { [string]$f.uploadState } else { [string]$f.uploadstate }
if ($state -eq $TargetState) { return $f }
if ($state -match 'Failed') { throw "Upload-State '$state' (erwartet '$TargetState') — Graph hat den Schritt abgelehnt." }
if ((Get-Date) -gt $deadline) { throw "Timeout beim Warten auf '$TargetState' (letzter State: '$state')." }
Start-Sleep -Milliseconds 1500
}
}
function Send-GraphContentToAzureBlob {
# Schritt 4: verschluesselte Bytes als Block-Blob in die SAS-URL laden.
# Chunked (6 MB). WICHTIG: die Bytes werden als ISO-8859-1-String gesendet
# (1:1 Byte<->Zeichen) mit content-type 'text/plain; charset=iso-8859-1' —
# NICHT als rohes byte[]. Invoke-WebRequest verfaelscht in PS 5.1 binaere
# byte[]-Bodies (Bytes > 127), was korrupten Content nach Azure laedt.
# Schema 1:1 aus der MSEndpointMgr/IntuneWin32App-Referenz uebernommen.
param([Parameter(Mandatory=$true)][string]$SasUri, [Parameter(Mandatory=$true)][byte[]]$Bytes)
$isoEncoding = [System.Text.Encoding]::GetEncoding("iso-8859-1")
$chunkSize = 6 * 1024 * 1024
$total = $Bytes.Length
$blockIds = New-Object System.Collections.Generic.List[string]
$idx = 0
for ($offset = 0; $offset -lt $total; $offset += $chunkSize) {
$len = [Math]::Min($chunkSize, $total - $offset)
$chunk = New-Object byte[] $len
[Array]::Copy($Bytes, $offset, $chunk, 0, $len)
# Block-ID: base64 einer 0-gepaddeten Nummer (gleiche Laenge fuer alle)
$blockId = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($idx.ToString('D6')))
$blockIds.Add($blockId)
$encodedChunk = $isoEncoding.GetString($chunk)
$putUri = "$SasUri&comp=block&blockid=$([uri]::EscapeDataString($blockId))"
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'content-type' = 'text/plain; charset=iso-8859-1' }
Invoke-WebRequest -Uri $putUri -Method PUT -Headers $headers -Body $encodedChunk -UseBasicParsing | Out-Null
$idx++
}
# Block-Liste committen (Invoke-RestMethod, content-type text/plain; charset=UTF-8)
$xml = '<?xml version="1.0" encoding="utf-8"?><BlockList>'
foreach ($b in $blockIds) { $xml += "<Latest>$b</Latest>" }
$xml += '</BlockList>'
$listUri = "$SasUri&comp=blocklist"
Invoke-RestMethod -Uri $listUri -Method PUT -Body $xml -Headers @{ 'content-type' = 'text/plain; charset=UTF-8' } | Out-Null
}
function Invoke-GraphContentRenewUpload {
param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId)
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId/renewUpload"
$null = Invoke-MgGraphRequestRetry -Uri $uri -Method POST
}
function Invoke-GraphContentCommit {
# Schritt 5: commit mit fileEncryptionInfo.
param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId, [hashtable]$EncryptionInfo)
$body = @{ fileEncryptionInfo = $EncryptionInfo } | ConvertTo-Json -Depth 5 -Compress
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId/commit"
$null = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $body -ContentType 'application/json'
}
function Update-GraphAppContent {
# Orchestrator: laedt eine neue Setup-Datei hoch und aktiviert sie.
# Phase 1 unterstuetzt .intunewin (win32LobApp). $ProgressCb (scriptblock)
# optional fuer Schritt-Logging. Gibt das Ergebnis-Objekt zurueck.
param(
[Parameter(Mandatory=$true)][string]$AppId,
[Parameter(Mandatory=$true)][string]$GraphTypeRaw, # z.B. '#microsoft.graph.win32LobApp'
[Parameter(Mandatory=$true)][string]$FilePath,
[string]$DisplayVersion,
[scriptblock]$ProgressCb
)
$report = { param($step) if ($ProgressCb) { & $ProgressCb $step } ; Write-Host " [CONTENT] $step" -ForegroundColor DarkCyan }
$graphType = ($GraphTypeRaw -replace '^#microsoft\.graph\.', '') # win32LobApp
$odataCast = "graph.$graphType"
# 1) Paket lesen (Phase 1: nur .intunewin)
& $report "Lese Paket"
$ext = [IO.Path]::GetExtension($FilePath).ToLower()
if ($ext -ne '.intunewin') { throw "Phase 1 unterstuetzt nur .intunewin. Datei: $ext" }
$pkg = Read-IntuneWinPackage -Path $FilePath
# 2) contentVersion + file anlegen
& $report "Lege Content-Version an"
$cvId = New-GraphAppContentVersion -AppId $AppId -GraphType $odataCast
$fileId = New-GraphAppContentFile -AppId $AppId -GraphType $odataCast -CvId $cvId -Name $pkg.FileName -Size $pkg.Size -SizeEncrypted $pkg.SizeEncrypted
# 3) auf SAS warten
& $report "Warte auf Azure-Speicher-URL"
$f = Wait-GraphContentFileState -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -TargetState 'azureStorageUriRequestSuccess' -TimeoutSec 120
$sas = if ($f.azureStorageUri) { [string]$f.azureStorageUri } else { [string]$f.azurestorageuri }
if (-not $sas) { throw "Keine azureStorageUri erhalten" }
# 4) Upload zu Azure Blob
& $report "Lade Datei hoch ($([int]($pkg.SizeEncrypted/1MB)) MB)"
Send-GraphContentToAzureBlob -SasUri $sas -Bytes $pkg.EncryptedBytes
# 5) commit
& $report "Committe Datei"
Invoke-GraphContentCommit -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -EncryptionInfo $pkg.EncryptionInfo
# 6) auf commit-Erfolg warten
& $report "Warte auf Commit-Bestaetigung"
$null = Wait-GraphContentFileState -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -TargetState 'commitFileSuccess' -TimeoutSec 180
# 7) App auf neue Version zeigen (+ optional displayVersion)
& $report "Aktiviere neue Version"
$patch = @{ committedContentVersion = $cvId }
if ($DisplayVersion) { $patch['displayVersion'] = $DisplayVersion }
Update-GraphMobileApp -AppId $AppId -AppTypeRaw $GraphTypeRaw -Patch $patch
& $report "Fertig"
return @{ ok = $true; contentVersion = $cvId; fileId = $fileId; size = $pkg.Size; sizeEncrypted = $pkg.SizeEncrypted; displayVersion = $DisplayVersion }
}
function Get-GraphAppDetailsBatch {
# Holt App-Stammdaten + installSummary + relationships in EINEM
# HTTP-Roundtrip via Microsoft Graph $batch. Server-seitig parallelisiert
# — statt 3 sequenzieller Calls (jeder ~300-800ms) nur ein einziger
# Roundtrip in der Zeit des langsamsten Sub-Calls.
# Falls ein Sub-Call serverseitig fehlschlaegt, wird der jeweilige
# Teil $null gesetzt — der Rest funktioniert weiter.
param([Parameter(Mandatory=$true)][string]$AppId)
$body = @{
requests = @(
@{ id = 'app'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$($AppId)?`$expand=categories" },
@{ id = 'summary'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$AppId/installSummary" },
@{ id = 'rels'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$AppId/relationships" }
)
}
$json = $body | ConvertTo-Json -Depth 5 -Compress
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$resp = $null
try {
$resp = Invoke-MgGraphRequest `
-Uri 'https://graph.microsoft.com/beta/$batch' `
-Method POST `
-Body $json `
-ContentType 'application/json'
} catch {
Write-Host " [BATCH] $AppId fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
return @{ App = $null; InstallSummary = $null; Relationships = @() }
}
$sw.Stop()
$result = @{ App = $null; InstallSummary = $null; Relationships = @() }
$stati = @()
foreach ($r in @($resp.responses)) {
$st = [int]$r.status
$id = [string]$r.id
$stati += "$id=$st"
switch ($id) {
'app' {
if ($st -eq 200) { $result.App = $r.body }
}
'summary' {
if ($st -eq 200) { $result.InstallSummary = $r.body }
}
'rels' {
if ($st -eq 200 -and $r.body.value) {
$result.Relationships = @($r.body.value)
}
}
}
}
Write-Host " [BATCH] $AppId in $($sw.ElapsedMilliseconds)ms ($($stati -join ', '))" -ForegroundColor DarkGray
return $result
}
function Get-GraphMobileAppInstallSummary {
# Installations-Statistik einer App. Endpoint ist Singular ("installSummary"),
# nicht zu verwechseln mit deviceStatuses/userStatuses (Per-Device/Per-User).
param([Parameter(Mandatory=$true)][string]$AppId)
try {
return Invoke-MgGraphRequest `
-Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/installSummary" `
-Method GET
} catch {
return $null
}
}
function Get-GraphMobileAppRelationships {
# Liefert die Beziehungen einer App (Dependency / Supersedence). 400-Fehler
# beim Delete kommen oft daher, dass eine andere App diese App als Dependency
# oder Supersedence referenziert - dann muss erst die Gegenseite entfernt werden.
param([Parameter(Mandatory=$true)][string]$AppId)
try {
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/relationships"
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
$items = @()
if ($resp.value) { $items = @($resp.value) }
Write-Host " [RELS] $AppId -> $($items.Count) Eintraege via /relationships" -ForegroundColor DarkGray
if ($items.Count -gt 0) {
$byType = $items | Group-Object { [string]$_.'@odata.type' } | ForEach-Object { "$($_.Name)=$($_.Count)" }
Write-Host " [RELS] Typen: $($byType -join ', ')" -ForegroundColor DarkGray
}
return $items
} catch {
Write-Host " [RELS] Lesen fehlgeschlagen fuer $AppId : $($_.Exception.Message)" -ForegroundColor DarkYellow
return @()
}
}
function Remove-GraphMobileApp {
# Versucht die App ueber beta zu loeschen; fallback auf v1.0 falls beta 400/404 gibt.
# Wirft eine angereicherte Exception mit der Original-Graph-Fehlermeldung.
param([Parameter(Mandatory=$true)][string]$AppId)
$endpoints = @(
"https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId",
"https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$AppId"
)
$lastErr = $null
foreach ($uri in $endpoints) {
try {
$null = Invoke-MgGraphRequest -Uri $uri -Method DELETE
return # success
} catch {
$lastErr = $_
# ErrorDetails.Message ist meist der Raw-JSON-Body von Graph
$body = $null
try { $body = $_.ErrorDetails.Message } catch {}
# 405/Resource-not-found auf beta -> v1.0 probieren. Bei 400 ebenfalls
# einen Versuch wagen, das ist im Test mit win32LobApp gelegentlich noetig.
$msg = "$($_.Exception.Message) $body"
if ($msg -match '404|400|405|MethodNotAllowed|NotSupported') {
continue
} else {
throw
}
}
}
# Beide Endpoints haben versagt -> letzte Fehlermeldung anreichern und werfen
if ($lastErr) {
$body = $null
try { $body = $lastErr.ErrorDetails.Message } catch {}
$detail = if ($body) { " | Graph-Response: $body" } else { "" }
throw [System.Exception]::new("Delete fehlgeschlagen: $($lastErr.Exception.Message)$detail")
}
}
# Excluded App-Typen aus dem Original-Script
$script:ExcludedAppTypes = @(
'#microsoft.graph.iosLobApp', '#microsoft.graph.iosStoreApp', '#microsoft.graph.iosVppApp',
'#microsoft.graph.managedIOSLobApp', '#microsoft.graph.managedIOSStoreApp',
'#microsoft.graph.androidLobApp', '#microsoft.graph.androidStoreApp', '#microsoft.graph.androidForWorkApp',
'#microsoft.graph.androidManagedStoreApp', '#microsoft.graph.androidManagedStoreWebApp',
'#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp',
'#microsoft.graph.macOSDmgApp', '#microsoft.graph.macOSLobApp', '#microsoft.graph.macOSMicrosoftDefenderApp',
'#microsoft.graph.macOSMicrosoftEdgeApp', '#microsoft.graph.macOSOfficeSuiteApp', '#microsoft.graph.macOSPkgApp',
'#microsoft.graph.macOsVppApp'
)
function Test-AppTypeAllowed {
param([string]$Type)
return -not ($script:ExcludedAppTypes -contains $Type)
}
function ConvertTo-AppFriendlyType {
param([string]$Type)
switch ($Type) {
'#microsoft.graph.win32LobApp' { 'Win32' }
'#microsoft.graph.windowsStoreApp' { 'Store' }
'#microsoft.graph.microsoftStoreForBusinessApp' { 'MS Store' }
'#microsoft.graph.officeSuiteApp' { 'M365 Apps' }
'#microsoft.graph.windowsMicrosoftEdgeApp' { 'Edge' }
'#microsoft.graph.windowsWebApp' { 'WebApp' }
'#microsoft.graph.winGetApp' { 'WinGet' }
'#microsoft.graph.windowsPhone81AppX' { 'APPX' }
'#microsoft.graph.windowsAppX' { 'APPX' }
'#microsoft.graph.windowsUniversalAppX' { 'APPX' }
'#microsoft.graph.windowsMobileMSI' { 'MSI' }
default {
($Type -replace '#microsoft\.graph\.','')
}
}
}
function Get-GraphErrorFriendly {
param([Parameter(Mandatory=$true)]$ErrorRecord)
$msg = $ErrorRecord.Exception.Message
switch -Regex ($msg) {
"already exist|bereits vorhanden|One or more added object references already exist" {
return @{ Code="MemberExists"; Friendly="Mitglied bereits in Gruppe"; IsWarning=$true; Full=$msg }
}
"Insufficient privileges|Access denied|Authorization_RequestDenied|403" {
return @{ Code="Forbidden(403)"; Friendly="Keine Berechtigung. Erforderlich: Group.ReadWrite.All / DeviceManagementApps.ReadWrite.All"; IsWarning=$false; Full=$msg }
}
"does not exist|not found|Request_ResourceNotFound|404" {
return @{ Code="NotFound(404)"; Friendly="Objekt nicht gefunden"; IsWarning=$false; Full=$msg }
}
"Too many requests|throttled|429" {
return @{ Code="Throttled(429)"; Friendly="Zu viele Anfragen. Bitte warten."; IsWarning=$false; Full=$msg }
}
"BadRequest|400" {
if ($msg -match "exist|vorhanden|member") {
return @{ Code="MemberExists"; Friendly="Mitglied bereits in Gruppe"; IsWarning=$true; Full=$msg }
}
return @{ Code="BadRequest(400)"; Friendly="Ungueltige Anfrage"; IsWarning=$false; Full=$msg }
}
default {
return @{ Code="Error"; Friendly=$msg; IsWarning=$false; Full=$msg }
}
}
}
function Test-AppVendorRule {
# True wenn die App den angegebenen Detection-Regel-Block matcht.
param($RawApp, $Rule)
if (-not $Rule -or -not $Rule.field -or -not $Rule.pattern) { return $false }
$value = switch ($Rule.field) {
'commandLine' { @($RawApp.installCommandLine, $RawApp.uninstallCommandLine) -join " " }
'developer' { [string]$RawApp.developer }
'publisher' { [string]$RawApp.publisher }
'displayName' { [string]$RawApp.displayName }
'notes' { [string]$RawApp.notes }
'owner' { [string]$RawApp.owner }
default { "" }
}
if (-not $value) { return $false }
switch ($Rule.match) {
'equals' { return ($value -eq $Rule.pattern) }
'startsWith' { return $value.StartsWith($Rule.pattern, [StringComparison]::OrdinalIgnoreCase) }
'regex' {
try { return ($value -match $Rule.pattern) }
catch { return $false } # ungueltiger Regex -> Vendor matcht halt nicht
}
default { return ($value -match [regex]::Escape($Rule.pattern)) } # 'contains'
}
}
function Resolve-AppVendorSource {
# Iteriert ueber Settings.vendors und liefert displayName des ersten
# passenden Vendors. Default: "Intune" wenn keiner matcht.
param($RawApp, $Vendors)
if ($null -eq $Vendors) { return "Intune" }
foreach ($v in @($Vendors)) {
if ($v -and (Test-AppVendorRule -RawApp $RawApp -Rule $v.detection)) {
return [string]$v.displayName
}
}
return "Intune"
}
function Format-AppForFrontend {
param($RawApp, $AllGroupsLookup)
# AllGroupsLookup: hashtable groupId -> displayName (fuer Anzeige der zugewiesenen Gruppen)
$available = @()
$required = @()
foreach ($a in @($RawApp.assignments)) {
$tgt = $a.target
$tgtType = $tgt.'@odata.type'
$entry = $null
if ($tgtType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') {
$entry = @{ GroupId="ALL_USERS"; GroupName="All Users"; IsNative=$true }
} elseif ($tgtType -eq '#microsoft.graph.allDevicesAssignmentTarget') {
$entry = @{ GroupId="ALL_DEVICES"; GroupName="All Devices"; IsNative=$true }
} elseif ($tgtType -eq '#microsoft.graph.groupAssignmentTarget') {
$gid = $tgt.groupId
$name = if ($AllGroupsLookup -and $AllGroupsLookup.ContainsKey($gid)) { $AllGroupsLookup[$gid] } else { $gid }
$entry = @{ GroupId=$gid; GroupName=$name; IsNative=$false }
} elseif ($tgtType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') {
continue
}
if ($null -ne $entry) {
switch ($a.intent) {
'available' { $available += $entry }
'required' { $required += $entry }
}
}
}
# Version aus moeglichen Feldern (je nach App-Typ)
$version = $null
foreach ($f in @('displayVersion','version','productVersion','identityVersion','officeSuiteAppVersion')) {
if ($RawApp.$f) { $version = [string]$RawApp.$f; break }
}
# Quelle: PMPC-Direkterkennung zuerst (das hat schon immer funktioniert,
# bleibt damit auch bei kaputter Settings/Vendor-Registry zuverlaessig).
# Erst wenn das nicht greift, fragen wir die Vendor-Registry — dort liegen
# Robopack & weitere konfigurierbare Vendoren.
$source = "Intune"
if ($RawApp.installCommandLine -and $RawApp.installCommandLine -match "PatchMyPC") {
$source = "PatchMyPC"
} elseif ($RawApp.uninstallCommandLine -and $RawApp.uninstallCommandLine -match "PatchMyPC") {
$source = "PatchMyPC"
} else {
$vendorSource = Resolve-AppVendorSource -RawApp $RawApp -Vendors $script:Settings.vendors
if ($vendorSource -and $vendorSource -ne "Intune" -and $vendorSource -ne "PatchMyPC") {
$source = $vendorSource
}
}
return [pscustomobject]@{
AppId = $RawApp.id
AppName = $RawApp.displayName
AppType = ConvertTo-AppFriendlyType -Type $RawApp.'@odata.type'
AppTypeRaw = $RawApp.'@odata.type'
Publisher = $RawApp.publisher
Version = $version
Source = $source
IsAssigned = ($RawApp.isAssigned -eq $true)
AvailableGroups = $available
RequiredGroups = $required
AvailableCount = $available.Count
RequiredCount = $required.Count
# Kategorien fuer Filter + Anzeige. Get-AppCategoryNames liegt in Api.ps1,
# ist aber global dot-sourced. Liefert immer ein String-Array (ggf. leer).
Categories = (Get-AppCategoryNames -RawCategories $RawApp.categories -AppId $RawApp.id)
# ISO-Strings damit JS new Date() es zuverlaessig parst — fuer Filter
# nach Aenderungs-Zeitraum (App-Liste, ohne Detail-Roundtrip).
CreatedDateTime = ConvertTo-IsoDate $RawApp.createdDateTime
LastModifiedDateTime = ConvertTo-IsoDate $RawApp.lastModifiedDateTime
}
}
@@ -0,0 +1,295 @@
# 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 = ""
# 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")
}
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 muss eine GUID sein."
}
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")
}
}
@@ -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")
}
}
@@ -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)
}
File diff suppressed because it is too large Load Diff
+341
View File
@@ -0,0 +1,341 @@
# Intune App-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** — beim ersten Start öffnet sich der Onboarding-Screen. *Anmeldung starten* klicken → Microsoft-Anmeldefenster bestätigen.
2. **Empfänger wählen** — linke Spalte. Wechsle zwischen *Abteilung*, *RPA* oder *Benutzer* und setze die Haken.
3. **Apps laden** — falls noch nicht geschehen, klicke das *Refresh*-Icon rechts oben in der Apps-Spalte.
4. **Zuweisung sammeln** — bei einer App auf den **Available**- oder **Required**-Button klicken. Die ausgewählten Empfänger landen in der Session (rechte Spalte).
5. **Ausführen** — rechts unten *Zuweisungen ausführen*. Ein HTML-Report öffnet sich automatisch.
---
## Apps zuweisen — der Hauptworkflow
Das ist der zentrale Anwendungsfall des Tools. Du nimmst eine Liste von **Empfängern** (Abteilungen, RPA-Gruppen oder Einzel-User), suchst eine oder mehrere **Apps** und fügst die Empfänger den **bereits an der App zugewiesenen Gruppen** als Mitglieder hinzu.
### Das mentale Modell
Das Tool ändert **nicht die App-Zuweisungen selbst** — die Liste der Available/Required-Gruppen einer App bleibt, was sie ist. Stattdessen ändert es die **Mitgliedschaften in genau diesen Gruppen**. Beispiel:
> Beispiel mit Platzhaltern. Die Präfixe `<dept-prefix>` und `intune-win-app-` sind in den Einstellungen konfigurierbar.
- App *Beispiel-App* hat die Required-Gruppe `intune-win-app-<app-slug>-required` zugewiesen.
- Du wählst links die Abteilung `<dept-prefix>-team-controlling` als Empfänger.
- Klick auf die *Required*-Pille der App → die Operation `Abteilung als Mitglied zur Required-Gruppe hinzufügen` wandert in die Session.
- Beim Ausführen wird `<dept-prefix>-team-controlling` als Mitglied in `intune-win-app-<app-slug>-required` aufgenommen.
- Intune ziehst dann beim nächsten Sync alle Mitglieder dieser Gruppe als Required-Targets der App.
### Schritt-für-Schritt
1. **Empfänger markieren (linke Spalte).** Du kannst Modi mischen — z. B. zwei Abteilungen *plus* drei Einzel-Benutzer gleichzeitig auswählen. Die *Pinned-Targets*-Leiste oben zeigt dir alles, was gerade aktiv ist.
2. **Apps öffnen (mittlere Spalte).** Filter benutzen oder Suchfeld. Du musst die Apps nicht einzeln durchgehen — du kannst nacheinander bei mehreren Apps Pillen klicken, alle Operationen sammeln und am Ende einmal ausführen.
3. **Available- oder Required-Button klicken.** Was passiert hängt vom Inhalt ab:
- **App hat genau eine Available/Required-Gruppe**: Die ausgewählten Empfänger werden direkt zur Session als „diese Gruppe + dieser Intent" hinzugefügt.
- **App hat mehrere Gruppen** (Count-Badge auf dem Button): Ein Picker öffnet sich und du wählst gezielt eine Gruppe aus der Liste.
- **App hat keine Gruppe** dieses Typs: Button ist deaktiviert.
4. **Status prüfen.** Die Buttons zeigen dir farblich an, wie der aktuelle Stand ist (siehe nächster Abschnitt).
5. **Bei mehreren Apps wiederholen.** Die Session sammelt alles. Wenn du dieselbe Pille nochmal klickst, wird der Vorgang aus der Session **wieder entfernt** (Toggle-Verhalten).
6. **Ausführen.** Rechts unten *Zuweisungen ausführen*. Das Tool durchläuft jede gesammelte Operation und schickt einen Member-Add an die Microsoft-Graph-API. Der HTML-Report listet Erfolge und Fehler je App.
### Bedeutung der Pillen-Farben
Die Buttons *Available* und *Required* an jeder App-Zeile signalisieren über Farbe und Indikator den Zustand **bezogen auf die gerade ausgewählten Empfänger**:
| Optik | Bedeutung |
|---|---|
| Heller Akzent-Rahmen, neutral | Standard-Zustand. Keine ausgewählten Empfänger oder noch nicht geprüft. |
| **Grüner Rahmen + grünes Häkchen** in der Zeile | **Alle** ausgewählten Empfänger sind bereits Mitglied einer der zugewiesenen Gruppen dieses Typs. Klick erzeugt evtl. Dubletten — meist nicht nötig. |
| **Gelber Rahmen + halbes Häkchen** | **Teilweise**: ein Teil der Empfänger ist schon Mitglied, der Rest nicht. Klick füllt die Lücken. |
| **Schwarz gefüllt + Count-Badge** (z. B. „3") | **Aktiv in der Session**: Operation für diese App ist schon gesammelt. Anzahl = wie viele Operationen für diese App in der Session liegen. Erneuter Klick entfernt sie wieder. |
| Italic Schrift, ausgegraut | **Native Zuweisung** (All Users / All Devices) ist die einzige Option — Mitgliedschaft kann nicht geändert werden, weil keine Gruppe existiert. |
### Picker bei mehreren Gruppen
Wenn eine App mehr als eine Available- oder Required-Gruppe hat, öffnet der Klick einen kleinen Picker:
- Liste aller Gruppen dieses Typs
- Pro Gruppe wird der **Member-Status** (Mitglied / Partial mit n/m-Anzeige) angezeigt, sofern Empfänger ausgewählt sind
- Klick auf eine Gruppe = zur Session hinzufügen oder entfernen (Toggle)
- Pro Gruppe zusätzlich die *Aktions-Icons* (Intune-Portal-Link, Mitglieder, Zuweisung löschen)
### Die Session (rechte Spalte)
Die rechte Spalte ist deine **Vorschau aller noch nicht ausgeführten Vorgänge**. Pro Session-Eintrag siehst du:
- App-Name + Gruppen-Name
- Empfänger-Tag (genau **ein** Empfänger pro Eintrag — bei mehreren Empfängern entstehen mehrere Einträge)
- A/R-Badge (Available oder Required)
- X-Button um den Eintrag wieder rauszunehmen
Darunter eine Zusammenfassung mit der **Gesamtzahl der Vorgänge** — der Apply-Button zeigt diese Zahl mit an.
### Ausführen und Report
Klick auf *Zuweisungen ausführen* → Bestätigungs-Dialog mit Counter → echte Ausführung:
- Für jeden Session-Eintrag wird ein Member-Add ausgeführt
- Native Targets (`ALL_USERS`/`ALL_DEVICES`) werden übersprungen — sie haben keine Gruppen-Mitgliedschaft
- Bereits-Mitglied-Fälle werden als „OK (bereits Mitglied)" geloggt — kein Fehler
- Echte Fehler (403 Berechtigung, 404 Gruppe nicht gefunden, …) werden mit Details aufgeführt
Nach Abschluss öffnet sich automatisch ein **HTML-Report** mit:
- Erfolgs-/Fehler-/Skipped-Counter + Erfolgsquote
- Empfänger-Chips (Abteilungen, Benutzer)
- Zuweisungs-Chips (App + Gruppe)
- Detail-Tabelle nach App gruppiert — Apps mit Fehlern sind aufgeklappt
Reports werden in `%TEMP%\IntuneAppManager-Reports\` abgelegt und können später erneut geöffnet werden.
### Wann sollte ich was nutzen?
| Du willst… | Workflow |
|---|---|
| 50 User aus einer neuen Abteilung in 10 Apps mitnehmen | Empfänger = Abteilung wählen, durch 10 Apps gehen, je 1 Required- oder Available-Pille klicken, ausführen. |
| Eine neue App nur für 3 Test-Benutzer ausrollen | User-Modus, 3 User markieren. Bei der App entweder eine bestehende Available-Gruppe wählen oder *+ → Available: Neue Gruppe* erzeugen. |
| Eine bestehende Gruppen-Zuweisung einer App entfernen | App aufklappen → *Zuweisungen*-Sektion → Mülleimer-Icon neben der Gruppe. |
| Eine zugewiesene Gruppe in mehreren Apps prüfen | Bei einer App auf die Pille klicken → Picker → Gruppe aussehen → Mitglieder-Icon → Intune-Portal Mitglieder-Ansicht. Oder via Filter „Mehrere Available/Required" eingrenzen. |
---
## Header (oben)
| Element | Funktion |
|---|---|
| **Logo links** | Kann in den Einstellungen durch dein Firmen-Logo ersetzt werden. |
| **Einstellungen** (Zahnrad-Icon) | Tenant/Client-ID, Naming-Schemata, Farben, Logo, Filter-Defaults. Wird unter `%APPDATA%\IntuneAppManager-Web\settings.json` persistiert. |
| **Hilfe** (Fragezeichen-Icon) | Diese Seite. |
| **Theme-Toggle** (Sonne/Mond-Slider) | Light/Dark-Mode umschalten. Wird im Browser-LocalStorage gemerkt. |
| **Avatar** (Personen-Icon) | Zeigt den angemeldeten Account. Mouseover für vollen UPN. Grüner Punkt = verbunden. |
| **Logout** (Tür-mit-Pfeil-Icon) | Trennt die Microsoft-Graph-Verbindung. |
---
## Linke Spalte — Empfänger
Drei Auswahl-Modi via Segment-Switch oben:
- **Abteilung** — lädt alle Gruppen, deren Name mit einem der in den Einstellungen konfigurierten Präfixe beginnt (mehrere möglich, z.B. `abt-hm` *und* `abt-extern` für interne plus externe Mitarbeiter — siehe *Einstellungen → Abteilungs-Gruppen*).
- **RPA** — lädt die in den Einstellungen explizit aufgelisteten Gruppennamen.
- **Benutzer** — Live-Suche über Microsoft Graph. Mindestens 2 Zeichen. Felder konfigurierbar (DisplayName, UPN, Mail, Department).
### Gruppen aufklappen
Der Pfeil `>` rechts an einer Gruppen-Zeile öffnet die **Mitglieder inline**. Mitglieder lassen sich einzeln per Checkbox markieren und mit *Benutzer übernehmen* in den User-Modus überführen — nützlich wenn nur ein Teil einer Gruppe zugewiesen werden soll.
### Icons pro Eintrag (immer sichtbar)
| Icon-Form | Wirkung |
|---|---|
| Externer-Link-Pfeil | Öffnet die Gruppe/User-Seite direkt im Intune-Portal in neuem Tab. |
| Zwei-Personen-Icon | (nur bei Gruppen) Öffnet die *Mitglieder-Ansicht* der Gruppe im Intune-Portal in neuem Tab. |
| Teams-T-im-Kasten | (nur bei Benutzern) Startet einen Teams-Chat mit der Person — öffnet die Teams-Desktop-App. |
### Mehrere Modi gleichzeitig
Die Auswahlen in den drei Modi sind voneinander unabhängig — du kannst etwa 5 Abteilungen + 2 RPA-Gruppen + 3 Einzel-Benutzer gleichzeitig anvisieren. Die *Pinned-Targets*-Leiste oben in der linken Spalte zeigt alles, was gerade markiert ist.
---
## Mittlere Spalte — Apps
### Filter
| Filter | Zweck |
|---|---|
| Suchfeld | Volltext-Suche im App-Namen. |
| Dropdown rechts | Preset-Filter: *Alle*, *Ohne Zuweisungen*, *Nur Available*, *Nur Required*, *Mehrere Available/Required*, *All Users/Devices*, *Nur Intune* sowie ein Eintrag *pro konfiguriertem Vendor* (z.B. *Nur PatchMyPC-Apps*, *Nur Robopack-Apps*). |
| `Bereits Gruppenmitglied` | Zeigt nur Apps, bei denen mindestens eine der ausgewählten Empfänger schon Gruppenmitglied einer zugewiesenen Gruppe ist. Funktioniert nur mit ausgewählten Empfängern. |
| `All Users/Devices ausblenden` | Native Zuweisungen aus der Anzeige rausnehmen. |
| `Geändert` (Datumsrange) | Filtert auf das *Last Modified*-Datum der App. |
### App-Zeile
Jede Zeile besteht von links nach rechts aus:
1. **Status-Indikator** (Häkchen wenn alle ausgewählten Empfänger schon Mitglied einer zugewiesenen Gruppe sind).
2. **App-Name** + Hersteller. Klick öffnet das Detail-Panel.
3. **Rename-Icon** (Stift) und **Intune-Link**. Bei Vendor-verwalteten Apps (PatchMyPC, Robopack, …) ist der Stift durch ein **Lock-Icon** ersetzt.
4. **Source-Logo** — Intune, PatchMyPC, Robopack oder ein weiterer in den Einstellungen konfigurierter Vendor. Klick öffnet das jeweilige Portal in einem neuen Tab.
5. **Typ-Chip** (WIN32, MSI, APPX, WINGET, …).
6. **Version**.
7. **Available**- und **Required**-Buttons mit Count (Details siehe *Apps zuweisen — der Hauptworkflow* oben).
8. **Plus-Button** — Zuweisung hinzufügen (siehe nächster Abschnitt).
9. **Delete-Icon** (Mülltonne) — App aus Intune entfernen. Bei Vendor-verwalteten Apps (PatchMyPC, Robopack, …) ersetzt durch ein Lock.
### Plus-Button — Zuweisung hinzufügen
Modal mit **sechs Optionen**, gruppiert in zwei Spalten:
**REQUIRED** (Pflichtinstallation)
- *Neue Gruppe* — erstellt eine neue Azure-AD-Sicherheitsgruppe nach dem Naming-Schema aus den Einstellungen und weist sie der App zu.
- *All Users* — native Zuweisung an alle lizenzierten Benutzer.
- *All Devices* — native Zuweisung an alle Geräte.
**AVAILABLE** (Selbstinstallation via Firmenportal)
- *Neue Gruppe* — analog, mit Available-Naming-Schema.
- *All Users* — App erscheint im Firmenportal aller Benutzer.
- *All Devices* — App ist auf allen Geräten verfügbar.
> **Wichtig**: Native Zuweisungen (*All Users*/*All Devices*) können je App und je Intent nur **einmal** existieren. Bereits aktive Kombinationen werden im Dialog **ausgegraut** — das verhindert 409-Fehler bei doppelten Anlagen.
> **Unterschied zum Hauptworkflow**: Der *Plus*-Button erstellt eine **neue Zuweisung an der App selbst** (z. B. eine neue Gruppe oder ein natives Target). Der Klick auf eine *Available*/*Required*-Pille fügt dagegen Empfänger zu einer **bereits zugewiesenen Gruppe** als Mitglieder hinzu.
---
## App-Detail (Klick auf App-Namen)
Klappt unter der App-Zeile auf. Inhalt:
| Sektion | Inhalt |
|---|---|
| **Zuweisungen** | Available + Required-Listen mit Klick-Aktionen (Intune-Link, Mitglieder, Löschen). |
| **Allgemein** | Hersteller, Entwickler, Owner, Version, Typ, Min-OS, Featured-Flag. |
| **Beschreibung** | Wird als **Markdown gerendert** — Tabellen, Listen, Code-Blöcke, fette Schrift, alles wie auf GitHub. |
| **Installation** | Datei, Setup-Pfad, Install- und Uninstall-Befehl. Copy-Icons für die Commands. |
| **Detection** | Detection-Regeln (Registry, Datei, MSI-ProductCode, PowerShell-Script). |
| **Return-Codes** | Exit-Codes mit `success` / `softReboot` / `hardReboot` / `retry`. |
| **Meta** | Erstellt + Geändert-Daten. Kombinierbar mit dem *Geändert*-Filter oben. |
| **Installationen** | Statistik aus Intune: Erfolgs-/Fehler-/Pending-/N/A-Counts getrennt für *Geräte* und *Benutzer*. Erfolgsquote als farbige Pille. |
| **Abhängigkeiten** | Visuelles Flow-Diagramm mit Klartext-Erklärung. Klick auf einen Dependency-Knoten springt zur Ziel-App (sofern in der aktuellen Liste). |
| **Supersedence** | Was diese App ersetzt/aktualisiert — analog mit Erklärung. |
### Dependencies und Supersedence — was bedeutet was?
**Dependency** (Abhängigkeit)
- Eine Dependency definiert, dass **diese App** eine andere App als **Voraussetzung** hat.
- *autoInstall*: Intune installiert die Dependency automatisch vor dieser App, sobald die App zugewiesen wird.
- *detect*: Die Dependency muss manuell vorhanden sein — Intune installiert sie nicht automatisch.
**Supersedence** (Ablösung)
- Beschreibt, dass **diese App** eine andere App **ablöst**.
- *update*: Vorhandene Installation der alten App wird aktualisiert, Daten/Konfiguration bleiben erhalten.
- *replace*: Die alte App wird **deinstalliert**, dann wird die neue App installiert. Daten gehen verloren.
---
## Einstellungen
Persistiert in `%APPDATA%\IntuneAppManager-Web\settings.json`. Beim Speichern werden nur die *betroffenen* Caches geleert — bei reinen Farb-/Logo-Änderungen kein Reload nötig.
| Sektion | Inhalt |
|---|---|
| Microsoft Graph Verbindung | Tenant- und Client-ID + Scopes. Änderungen erzwingen einen Disconnect. |
| Abteilungs-Gruppen | Ein oder mehrere Präfixe (eine pro Zeile) für den Abteilungs-Modus. |
| RPA-Gruppen | Explizite Liste der RPA-Gruppennamen (eine pro Zeile). |
| Benutzer-Suche | Welche Felder durchsucht werden (DisplayName / UPN / Mail / Department). |
| Required-Gruppen-Naming | Präfix + Suffix für die Auto-Generierung von Required-Gruppen-Namen. |
| Available-Gruppen-Naming | Analog für Available-Gruppen. |
| Branding (Logo) | Eigenes Logo hochladen (PNG/JPG/SVG/WEBP/GIF, max. 2 MB). Wird im Projekt-Verzeichnis als `branding-logo.*` gespeichert. |
| Farben | 8 Akzentfarben (Brand, Available, Required, Erfolg, Warnung, Fehler, Markierte Zeile, Detail-Bereich) + *Standardfarben wiederherstellen*-Button. |
---
## Wichtige Einschränkungen
- **Vendor-verwaltete Apps** (PatchMyPC, Robopack und weitere in den Einstellungen konfigurierte Vendoren) können weder gelöscht noch umbenannt werden über das Tool — das funktioniert nur im jeweiligen Vendor-Portal selbst. Die UI zeigt für diese Apps ein Lock-Icon statt Trash/Pencil und das Backend blockiert mit HTTP 409 (`code = "<vendorId>Managed"`). Detection-Regeln pro Vendor (Field/Match/Pattern) liegen in `settings.json` und sind dort editierbar — Standardwerte: PMPC = `commandLine contains "PatchMyPC"`, Robopack = `developer equals "Robopack"`.
- **Native Zuweisungen** (*All Users* / *All Devices*) sind je App und Intent **einzigartig**. Bereits aktive Kombinationen werden im Erstellen-Dialog ausgegraut.
- **Required-Zuweisungen** werden bei der nächsten Geräte-Sync von Intune **erzwungen** — Vorsicht bei produktiven Apps. Bei Änderungen an produktiven Apps lieber erst Available zuweisen.
- **App-Löschung** im Tool entfernt die App komplett aus Intune (inkl. aller Zuweisungen) — irreversibel. Gilt nicht für Vendor-verwaltete Apps (siehe oben).
- **Cache-Verhalten**: Apps und Gruppen werden nach dem ersten Laden im PowerShell-Prozess gecached. Beim Refresh-Icon wird neu geladen. Wenn du Apps oder Gruppen außerhalb des Tools änderst, manuell refreshen.
---
## Vendor-Erkennung (PatchMyPC, Robopack, …)
Das Tool unterscheidet zwischen **Vendor-verwalteten Apps** (die ueber ein externes Portal wie PatchMyPC oder Robopack ausgerollt werden) und **nativen Intune-Apps** (manuell hochgeladen). Vendor-Apps bekommen ein eigenes Source-Logo + Lock-Icons statt Trash/Pencil, weil Aenderungen in Intune beim naechsten Vendor-Sync ueberschrieben wuerden.
### Wie wird erkannt?
Pro Vendor gibt es eine **Detection-Regel** in den Einstellungen (`settings.json` → `vendors[]`). Eine Regel besteht aus:
- **field** — welches Intune-App-Feld geprueft wird:
- `commandLine` (default fuer PMPC): pruef install- und uninstall-Commandline
- `developer` (default fuer Robopack): Entwickler-Feld der App
- `publisher`: Publisher-Feld
- `displayName`: App-Name
- `notes`: Notes-Feld
- `owner`: Owner-Feld
- **match** — wie verglichen wird:
- `contains`: Substring (case-insensitive)
- `equals`: exakter Match
- `startsWith`: Prefix-Match
- `regex`: voller Regex
- **pattern** — der zu suchende String / Regex
### Standard-Regeln
| Vendor | Feld | Match | Pattern | Erklärung |
|---|---|---|---|---|
| PatchMyPC | `commandLine` | `contains` | `PatchMyPC` | PMPC schreibt seinen Namen in den Install-/Uninstall-Aufruf (z. B. `PatchMyPC-ScriptRunner.ps1`) |
| Robopack | `developer` | `equals` | `Robopack` | Robopack-Konvention setzt das Developer-Feld der App auf "Robopack" |
### Was, wenn meine Vendor-Apps nicht erkannt werden?
`settings.json` unter `%APPDATA%\IntuneAppManager-Web\` oeffnen und im `vendors[]`-Array die Detection-Regel anpassen. Beispiel: wenn deine Robopack-Apps statt im Developer-Feld ueber einen Prefix im Display-Namen erkennbar sind:
```json
{
"id": "robopack",
"displayName": "Robopack",
"portalUrl": "https://app.robopack.com/",
"logoFile": "robopack.png",
"detection": { "field": "displayName", "match": "startsWith", "pattern": "RP - " },
"block": { "delete": true, "rename": true }
}
```
Nach dem Aendern: Tool neu starten. Im Einstellungen-Modal → **Verbindung + Lookups testen** klicken — dort wird pro Vendor die aktuelle Trefferzahl angezeigt. Steht da `0`, greift die Regel nicht.
### Reihenfolge im `vendors[]`-Array
Der **erste passende Vendor gewinnt**. Wenn du mehrere ueberlappende Regeln hast (z.B. PMPC erkennt commandLine, Robopack erkennt developer — und eine App passt zu beiden), wird der Vendor der weiter oben in der Liste steht zugewiesen. PMPC ist im Default oben.
### Eigenen Vendor hinzufuegen
Neuer Eintrag im `vendors[]`-Array — beliebige `id` (klein, ohne Sonderzeichen, wird Teil des HTTP-Status-Codes `<id>Managed`), eigener `displayName`, eigenes Logo unter `<id>.png` im Projekt-Root. Wenn das Logo fehlt, zeigt das Tool den ersten Buchstaben des Namens als Fallback-Badge.
---
## Setup-Datei aktualisieren
Native Intune-Apps koennen mit einer neuen Setup-Datei aktualisiert werden, ohne die App neu anzulegen — Zuweisungen, Detection-Rules und Einstellungen bleiben erhalten, nur der ausgerollte Datei-Inhalt (und optional die Versionsnummer) wird ersetzt.
- **Wo:** App-Detail aufklappen → Abschnitt **Setup-Datei** → *Setup-Datei aktualisieren…*. Der Button erscheint nur bei unterstuetzten, **nicht** vendor-verwalteten Apps (PatchMyPC/Robopack sind ausgeschlossen — die werden im jeweiligen Portal aktualisiert).
- **Unterstuetzt (aktuell):** `.intunewin` (Win32-Apps). MSI und MSIX folgen.
- **Datei-Quelle:** ein **lokaler Pfad auf dem Rechner, auf dem das Tool laeuft** (der Server liest die Datei direkt von der Platte — kein Browser-Upload). Beispiel: `C:\Pakete\app-2.0\app.intunewin`.
- **Ablauf:** Pfad eingeben → optional neue Versionsnummer → Bestaetigung anhaken → *Hochladen & aktivieren*. Das Tool legt eine neue Content-Version an, laedt die (bereits durch IntuneWinAppUtil verschluesselte) Datei nach Azure, committet sie und stellt die App auf die neue Version um.
> **Achtung:** Das ersetzt die ausgerollte Version. Alle zugewiesenen Geraete bekommen beim naechsten Sync die neue Datei. Nicht rueckgaengig machbar — **immer zuerst an einer Test-App ausprobieren.**
---
## Tastatur / Tipps
- **Ctrl+F5** — Hard-Refresh (löscht den Browser-Cache vollständig, lädt das JS/CSS neu — Pflicht nach Tool-Updates).
- **Esc** in jedem Modal — schließen.
- **Enter** im *Umbenennen*-Modal — speichern.
- **Klick auf eine App-Zeile** außerhalb von Buttons/Links — Detail-Panel öffnen/schließen.
- **Beim Apply** wird der HTML-Report automatisch im Browser geöffnet — kann später in `%TEMP%\IntuneAppManager-Reports\` wieder aufgerufen werden.
---
## Fehlerbehebung
| Symptom | Ursache / Lösung |
|---|---|
| „User canceled authentication" beim Verbinden | Das WAM-Anmeldefenster ist hinter anderen Fenstern verborgen. Das Tool versucht es automatisch nach vorne zu holen — wenn das nicht hilft, schaue in der Taskleiste oder klicke nochmal *Verbinden*. |
| Apps laden ewig | Bei großen Tenants (> 1000 Apps) kann das initiale Laden 10–30 s dauern. Apps werden danach gecached. Neu laden über Refresh-Icon. |
| Änderungen am Settings/Logo wirken nicht | Browser-Cache. **Ctrl+F5** drücken. |
| „Gruppe existiert bereits" beim Erstellen | Wahrscheinlich gibt es schon eine Gruppe mit dem generierten Namen. Im Dialog den Vorschlag manuell anpassen. |
| PowerShell-Konsole zeigt Errors beim Start | Module fehlen oder sind zu alt. `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force` ausführen. |
| Apply-Button bleibt grau / disabled | Es sind 0 Vorgänge in der Session, oder alle gesammelten Operationen sind native Targets (werden übersprungen). Mindestens einen Empfänger + Pille setzen. |
---
Stand: Mai 2026 · Tool-Version siehe Konsolen-Output beim Start und im Apply-Report-Footer. Hilfe-Datei: `www/help.md`. Vollständige Änderungshistorie: `CHANGELOG.md`.
@@ -0,0 +1,807 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune App-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 App-Manager</div>
</div>
</div>
<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">
<!-- 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>
<!-- 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 (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 im Entra Admin Center. Erforderlich: <em>Allow public client flows</em> = Ja, Redirect URI <code>http://localhost</code>.</div>
</div>
<div class="form-group">
<label class="lbl" for="setScopes">Scopes (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.Read.All"></textarea>
<div class="form-hint">Die Standard-Liste passt fuer den vollen Funktionsumfang. Weniger Scopes = weniger Rechte.</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>
<!-- 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>
@@ -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 App-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>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long