v0.1.24 — App Report, CSV Import, Read-Only Mode, Group Management
- App Report: Installationszähler pro App via Graph $batch (deviceStatuses) - Geräte-Export: CSV mit Maschinennamen pro App (GET /api/apps/devicestatus) - CSV-Import: Massenimport von Benutzern in Gruppen mit Header-Erkennung - Read-Only-Modus: Schreib-Tabs/Buttons automatisch ausblenden - Ergebnis-Banner nach CSV-Import (hinzugefügt/bereits Mitglied/Fehler/nicht gefunden) - Dokumentation aktualisiert (README, help.md, CHANGELOG) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# PowerShell & Windows
|
||||||
|
*.log
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Settings (may contain secrets)
|
||||||
|
# Uncomment to exclude settings:
|
||||||
|
# AppData/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node (if ever used)
|
||||||
|
node_modules/
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Format orientiert an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
||||||
|
Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solange das Tool noch in der `0.x.y`-Phase ist, sind Breaking Changes erlaubt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.24] - 2026-07-03
|
||||||
|
|
||||||
|
Navigation-Tabs, Group Management, Read-Only-Modus, App Report, CSV-Import.
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Navigation-Tabs** im Topbar: *App Management* | *Group Management* | *App Report* — Topbar-Grid auf 3 Spalten (Brand | Nav | Actions) erweitert
|
||||||
|
- **Group Management** als eigene View (statt Modal): Mitglieder & Export, Benutzer hinzufügen, CSV-Import — drei Tabs, linke Gruppen-Suche + rechte Aktionspane
|
||||||
|
- **Read-Only-Modus**: `clientIdRo` + `scopesRo` in Settings; Verbindung versucht RW, fällt bei Fehler auf RO zurück; `body.read-only`-CSS-Klasse blendet alle Schreib-Elemente aus (Buttons, Tabs, Formular-Bereiche)
|
||||||
|
- **−R / −A Buttons** pro App-Zeile: Gruppe direkt aus Required- oder Available-Zuweisung entfernen (mit Gruppen-Picker bei mehreren)
|
||||||
|
- **App Report** (neuer Tab): Installationszähler für alle Apps via `deviceStatuses` aggregiert; Tabelle sortierbar, Suchfilter; CSV-Export der gefilterten Ansicht
|
||||||
|
- **Geräte-Export pro App**: Button in jeder Zeile des App Reports → `GET /api/apps/devicestatus?appId=` → CSV mit Gerätename, Benutzer, Status, Fehlercode, OS-Version, Sync-Datum
|
||||||
|
- **CSV-Import** im Group Management: UPNs/E-Mails einfügen oder CSV hochladen; automatische Spalten-Erkennung (Header `UserPrincipalName`/`Mail`/`Email`/`UPN`; Quoted-Fields; `,`/`;`/Tab); Graph-Batch-Lookup; Vorschau-Tabelle mit ✓/✗; Ergebnis-Banner mit farbigen Chips (hinzugefügt / bereits Mitglied / Fehler / nicht gefunden)
|
||||||
|
- **Toast-Z-Index** auf 3000 angehoben (war 1000 → lag hinter Modals mit z-index 1500)
|
||||||
|
- **Neuer Endpoint** `POST /api/groups/resolve-upns` — UPN-Liste → UserId + DisplayName via $batch
|
||||||
|
- **Neuer Endpoint** `GET /api/apps/devicestatus?appId=` — Geräte-Installationsstatus einer App
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Group Management Modal** zur eigenständigen View umgebaut — keine doppelten IDs mehr
|
||||||
|
- **`openGroupExportModal()`** leitet jetzt auf `switchMainView('groups')` um
|
||||||
|
- **`switchMainView(view)`** verwaltet alle drei Views (apps / groups / report)
|
||||||
|
- **`applyReadOnlyMode()`** schaltet aktiven Schreib-Tab automatisch auf Export zurück wenn Read-Only einsetzt
|
||||||
|
- **Settings-Modal**: Felder `setClientIdRo` und `setScopesRo` hinzugefügt
|
||||||
|
- **`GET /api/status`**: liefert jetzt `readOnly: bool`
|
||||||
|
- **`POST /api/connect`**: setzt `$script:State.ReadOnly` nach RO-Fallback
|
||||||
|
- **`POST /api/disconnect`**: setzt `ReadOnly = $false` zurück
|
||||||
|
- **App Report Backend**: mehrfach überarbeitet — `installSummary` (400 für alle App-Typen ohne `DeviceManagementManagedDevices.Read.All`), `getAppInstallSummaryReport` (404 in beta und v1.0 ohne MDM-Berechtigung) → finaler Ansatz: `deviceStatuses` via `$batch` aggregiert
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **`$script:State.ReadOnly` Property-Fehler**: `[pscustomobject]` erlaubt keine dynamischen Properties — `ReadOnly = $false` musste in der Definition in `Start.ps1` stehen
|
||||||
|
- **Duplicate-ID-Bug**: `gexSearch`, `gexMembersArea` etc. existierten doppelt (alt: im Modal-Body, neu: in `#groupMgmtView`) — Modal-Body geleert
|
||||||
|
|
||||||
|
### Technisch
|
||||||
|
|
||||||
|
- `src/Models.ps1`: `clientIdRo`, `scopesRo` als optionale Settings-Felder
|
||||||
|
- `src/Api.ps1`: `Sync-ConfigFromSettings` + `Get-StatusEndpoint` + `Invoke-ConnectEndpoint` + `Invoke-DisconnectEndpoint` für RO-Logik erweitert; `Get-AppInstallReportEndpoint` (deviceStatuses-Aggregation); `Get-AppDeviceStatusEndpoint`; `Resolve-UpnsEndpoint`
|
||||||
|
- `www/styles.css`: `.topbar-nav`, `.nav-tab`, `.group-mgmt-view`, `.app-report-view`, `.app-report-table`, `.sortable`, `.btn-report-device-export`, `.csv-import-*`, `.csv-result-*`; `body.read-only`-Regeln erweitert um `#gexPaneImport` und `[data-tab="import"]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.23] - 2026-05-21
|
||||||
|
|
||||||
|
Setup-Datei nativer Apps aktualisierbar + Microsoft-Graph-Audit-Fixes.
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Setup-Datei aktualisieren** (Phase 1: `.intunewin` / win32LobApp): im App-Detail neuer Abschnitt *Setup-Datei* mit Button, der eine neue Setup-Datei von einem lokalen Server-Pfad hochlaedt und als neue contentVersion aktiviert — inkl. optionaler neuer Versionsnummer (`displayVersion`). Voller Graph-Upload-Flow: contentVersion → file → Azure-Blob-Chunk-Upload → commit → committedContentVersion. Vendor-verwaltete Apps (PMPC/Robopack) sind ausgeschlossen. MSI/MSIX folgen in spaeteren Phasen
|
||||||
|
- **Kategorie-Filter** in der App-Liste: neuer Dropdown, der sich dynamisch aus den vorkommenden App-Kategorien fuellt (inkl. *Ohne Kategorie*)
|
||||||
|
- **Kategorien-Anzeige** im App-Detail (immer sichtbar; "Default" wenn keine zugewiesen)
|
||||||
|
- **Mehrere Abteilungs-Praefixe** konfigurierbar (z.B. `abt-hm` *und* `abt-extern` parallel): neues Schema `departments.prefixes[]`, Settings-UI mit Textarea (eine pro Zeile). Der Graph-Filter wird OR-verkettet, ein einziger Listen-Request. Alter Single-String `prefix` wird weiter gelesen (Backward-Compat); Test-Endpoint zeigt Trefferzahl pro Praefix
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **App-Liste laedt `$expand=categories`** mit — `categories` ist eine Graph-Navigation-Property und kommt sonst leer (`null`) zurueck
|
||||||
|
- **Default-Scope** `DeviceManagementApps.Read.All` → `DeviceManagementApps.ReadWrite.All`: assign/rename/delete/content-update auf mobileApps brauchen ReadWrite (verifiziert via msgraph-Skill gegen den offiziellen Permission-Index)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **429/503-Throttling**: neuer Retry-Wrapper `Invoke-MgGraphRequestRetry` mit `Retry-After`-Honorierung + Backoff (2/5/10s) in `Get-GraphPaged` (alle Lese-Pfade) + `Add-GraphMember` (Bulk-Member-Add). 403/404 werden unveraendert durchgereicht
|
||||||
|
- **Kategorien** wurden nie angezeigt — Ursache war die fehlende `$expand`-Klausel (s.o.), nicht das Frontend
|
||||||
|
|
||||||
|
### Technisch
|
||||||
|
|
||||||
|
- Neue Graph-Funktionen: `Read-IntuneWinPackage`, `New-GraphAppContentVersion`, `New-GraphAppContentFile`, `Wait-GraphContentFileState`, `Send-GraphContentToAzureBlob`, `Invoke-GraphContentRenewUpload`, `Invoke-GraphContentCommit`, `Update-GraphAppContent`, `Invoke-MgGraphRequestRetry`
|
||||||
|
- Neuer Endpoint: `POST /api/apps/{id}/content` (`Update-AppContentEndpoint`), Body `{ filePath, displayVersion? }`
|
||||||
|
- Intune-Content-Verschluesselung dokumentiert (AES-256-CBC + HMAC-SHA256, Layout `[HMAC|IV|Ciphertext]`, ProfileVersion1) — fuer `.intunewin` aus Detection.xml uebernommen
|
||||||
|
- `api()`-Helper unterstuetzt jetzt `timeoutMs` (AbortController) fuer lange Upload-Requests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.22] - 2026-05-19
|
||||||
|
|
||||||
|
Robopack als zweiter Vendor analog zu PatchMyPC. Detection-Regeln wurden vom Hardcode in eine konfigurierbare Vendor-Registry verlagert.
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Robopack-Integration**: eigenes Source-Logo (`robopack.png`), Portal-Link auf `https://app.robopack.com/`, Lock-Icons fuer Delete/Rename, Filter-Eintrag "Nur Robopack-Apps". Default-Detection: `developer equals "Robopack"`
|
||||||
|
- **Vendor-Registry in `settings.json`**: neue Sektion `vendors[]` mit `id`, `displayName`, `portalUrl`, `logoFile`, `detection.field` (commandLine | developer | publisher | displayName | notes | owner), `detection.match` (contains | equals | regex | startsWith), `detection.pattern`, `block.delete`, `block.rename`. Mehrere Vendoren moeglich, erster Match gewinnt
|
||||||
|
- **Dynamisches Filter-Dropdown**: pro konfiguriertem Vendor wird eine Option `Nur <Name>-Apps` zur Laufzeit erzeugt
|
||||||
|
- **Vendor-Counts im Konfig-Test**: `Verbindung + Lookups testen` zeigt jetzt pro Vendor, wie viele Apps in der aktuellen Liste matchen — 0-Treffer warnen mit Hinweis auf die Detection-Regel
|
||||||
|
- **Letter-Fallback fuers Vendor-Logo**: wenn die Logo-Datei fehlt, zeigt der Badge den ersten Buchstaben des Vendor-Namens als Pille
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Detection-Logik in `src/Graph.ps1`**: `Format-AppForFrontend` ruft `Resolve-AppVendorSource` mit `Test-AppVendorRule` auf, statt hardcoded `match "PatchMyPC"`. `Source`-String entspricht `vendor.displayName` ("PatchMyPC", "Robopack", ...) — bestehende PMPC-Datensaetze unveraendert
|
||||||
|
- **Backend-Blocking generisch** (`Remove-AppEndpoint`, `Update-AppEndpoint`): pro Vendor pruefen ob `block.delete`/`block.rename` gesetzt ist, HTTP 409 mit `code = "<vendorId>Managed"` (z.B. `patchmypcManaged`, `robopackManaged`)
|
||||||
|
- **Frontend-Render generisch**: Source-Badge, Delete-Lock und Rename-Lock werden aus dem Vendor-Eintrag (`SettingsState.vendorsByDisplayName`) abgeleitet — keine PMPC-Hardcodes mehr im UI
|
||||||
|
- **`code`-Format geaendert** von `PatchMyPCManaged` zu `patchmypcManaged` (kleines `id` + `Managed`-Suffix) — Frontend-Stellen, die den exakten String pruefen, sind keine vorhanden
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- (keine; reine Feature-Erweiterung)
|
||||||
|
|
||||||
|
### Technisch
|
||||||
|
|
||||||
|
- Neue PowerShell-Funktionen: `Test-AppVendorRule`, `Resolve-AppVendorSource` (in `src/Graph.ps1`), `Find-VendorBySource` (in `src/Api.ps1`)
|
||||||
|
- Neue JS-Funktionen: `rebuildVendorIndexes(settings)`, `rebuildVendorFilterOptions()` (in `www/app.js`)
|
||||||
|
- Neue CSS-Klassen: `.app-source.app-source-letter` (Letter-Fallback), `.set-test-icon.warn` (gelbe Warn-Pille im Konfig-Test)
|
||||||
|
- Asset hinzu: `robopack.png` (Projekt-Root, ca. 53 KB)
|
||||||
|
- Migration: `Merge-Settings` ergaenzt fehlende `vendors`-Sektion aus den Defaults — bestehende `settings.json` bekommt PMPC + Robopack automatisch dazu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.21] - 2026-05-19
|
||||||
|
|
||||||
|
Multi-Tenant-Readiness: das Tool ist jetzt fuer fremde Tenants ausrollbar, ohne dass HanseMerkur-spezifische Werte im Code als Defaults erscheinen.
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **First-Run-Onboarding**: Bei leerem Tenant/Client/Praefix oeffnet sich das Settings-Modal automatisch beim Start. Setup-Banner im Modal und im Connect-Panel; Connect-Button bleibt disabled bis die kritischen Felder gefuellt sind
|
||||||
|
- **Konfiguration testen**: Neuer Button im Settings-Modal sowie neuer Endpoint `POST /api/settings/test` — prueft die aktuell eingetragenen Werte (auch vor dem Speichern) gegen Microsoft Graph: Verbindung, Abteilungs-Gruppen-Lookup mit Trefferzahl + Sample, RPA-Gruppen mit Found/Missing-Liste, User-Such-Probe. Inline-Ergebnis mit OK/FAIL-Pillen
|
||||||
|
- **Setup-Hilfetexte** unter den Connection-Feldern: Wo finde ich Tenant ID, Client ID, welche Redirect-URI ist noetig
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Defaults neutralisiert** (`Get-DefaultSettings` in `src/Models.ps1`): `tenantId`, `clientId`, `departments.prefix` sind leer, `rpa.groupNames = @()`. Scopes/Naming/Theme bleiben generisch. Bestehende `settings.json` werden ueber Merge-Settings unveraendert geladen
|
||||||
|
- **Hilfedatei generisch**: `abt-hm-bd24-controlling` und `intune-win-app-adobe-reader-required` durch Platzhalter (`<dept-prefix>-team-controlling`, `intune-win-app-<app-slug>-required`) ersetzt; "Default: abt-hm"-Hinweis entfernt
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Backend-Fallbacks entfernt**: `Get-GroupsEndpoint` liefert HTTP 412 + `code = "SettingsRequired"` wenn Praefix leer (vorher stiller `abt-hm`-Fallback). `Get-RpaGroupsEndpoint` liefert `notConfigured = $true` statt HM-RPA-Namen einzusetzen — Konfig-Probleme werden sichtbar statt kaschiert
|
||||||
|
- **PowerShell 5.1-Kompatibilitaet** (`src/Server.ps1`): `Read-RequestBody` nutzt `-AsHashtable` nur ab PS 6, in 5.1 wird ohne den Switch geparst. Endpoints handhaben sowohl Hashtable als auch PSCustomObject. Vorher: POST/PUT mit Body brach in Windows PowerShell 5.1 mit "Parameter 'AsHashtable' nicht gefunden"
|
||||||
|
- **Router-Logger-Bug** (`src/Router.ps1:33`): fehlende Parens um den `-f`-Format-Operator → PowerShell band `-f` als Prefix von `-ForegroundColor` und versuchte den Zeitstempel in eine ConsoleColor-Enum zu konvertieren. Folge: Unknown-Endpoint-Responses warfen 500er statt 404. Fix wie in Line 13/42 mit Klammern
|
||||||
|
|
||||||
|
### Technisch
|
||||||
|
|
||||||
|
- Neuer Endpoint: `POST /api/settings/test` (Body optional — testet uebergebene Werte gegen Graph, ohne sie zu speichern)
|
||||||
|
- Neue CSS-Klassen: `.banner-warning`, `.settings-test-result`, `.set-test-line`, `.set-test-icon.ok|fail`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.20] - 2026-05-19
|
||||||
|
|
||||||
|
Konsolidierter Stand aller bisherigen Iterationen. Erster offiziell versionierter Release.
|
||||||
|
|
||||||
|
### Neu — App-Verwaltung
|
||||||
|
|
||||||
|
- **App-Zuweisungs-Workflow** mit Session-Konzept: mehrere Apps nacheinander Pillen klicken → alle Vorgänge in einem Rutsch ausführen → HTML-Report
|
||||||
|
- **App umbenennen** via Stift-Icon (Modal mit alter/neuer Name)
|
||||||
|
- **App löschen** via Trash-Icon (mit Bestätigung; bei Graph-Fehler wird Original-Message + Relationship-Hinweis ausgegeben)
|
||||||
|
- **PatchMyPC-Apps**: Rename + Delete blockiert (Lock-Icon + HTTP 409 mit `code: PatchMyPCManaged`)
|
||||||
|
|
||||||
|
### Neu — Zuweisungen
|
||||||
|
|
||||||
|
- **Zuweisung-Erstellen-Dialog** mit 6 Optionen in zwei Spalten:
|
||||||
|
- Required: Neue Gruppe / All Users / All Devices
|
||||||
|
- Available: Neue Gruppe / All Users / All Devices
|
||||||
|
- **Aktive Native-Zuweisungen werden ausgegraut** — keine Duplikate möglich
|
||||||
|
- **Available-/Required-Gruppen-Naming** unabhängig konfigurierbar (Präfix + Suffix)
|
||||||
|
- **Zuweisung entfernen** direkt im App-Detail oder im Picker-Popover (Trash-Icon pro Gruppe)
|
||||||
|
- **Native Zuweisungen** (All Users / All Devices) per `POST /api/apps/{id}/assignments`
|
||||||
|
|
||||||
|
### Neu — App-Detail-Panel
|
||||||
|
|
||||||
|
- **2-Spalten-Layout** statt 3 (Auto-Fit), Wide-Sektionen für lange Inhalte
|
||||||
|
- **Markdown-Rendering** der App-Beschreibung (Tabellen, Listen, Code-Blöcke) via gebundeltes [marked v15](https://github.com/markedjs/marked)
|
||||||
|
- **Installations-Statistik** je App (Geräte + Benutzer × Installiert / Fehler / Pending / N/A) mit Erfolgsquote-Pille
|
||||||
|
- **Abhängigkeiten** mit SVG-Flow-Diagramm (orthogonale Linien, klickbare Knoten) + Klartext-Erklärung pro Relation
|
||||||
|
- **Supersedence** analog mit Klartext-Erklärung (update / replace)
|
||||||
|
- **Klick auf Flow-Knoten** springt zur Ziel-App und expandiert sie (mit Highlight-Flash)
|
||||||
|
|
||||||
|
### Neu — Filter & Suche
|
||||||
|
|
||||||
|
- **Datums-Range-Filter** für "Zuletzt geändert" (via gebundeltes [flatpickr v4](https://flatpickr.js.org/), deutsche Lokalisierung)
|
||||||
|
- **Preset-Dropdown** (Alle / Ohne Zuweisungen / Nur Available / Nur Required / Mehrere / All Users / All Devices / Intune / PatchMyPC)
|
||||||
|
- **Checkbox-Chips** "Bereits Gruppenmitglied" und "All Users/Devices ausblenden"
|
||||||
|
|
||||||
|
### Neu — Einstellungen
|
||||||
|
|
||||||
|
- **Settings-Cog** im Header öffnet Modal mit:
|
||||||
|
- Connection: Tenant-ID, Client-ID, Scopes
|
||||||
|
- Abteilungs-Gruppen: konfigurierbarer Präfix (Default `abt-hm`)
|
||||||
|
- RPA-Gruppen: explizite Liste
|
||||||
|
- Benutzer-Suche: konfigurierbare Felder (DisplayName / UPN / Mail / Department)
|
||||||
|
- Required + Available Naming-Schema (Präfix + Suffix)
|
||||||
|
- Branding: Logo-Upload (PNG/JPG/SVG/WEBP/GIF, max 2 MB)
|
||||||
|
- Farben: 8 Akzentfarben + "Standardfarben wiederherstellen"
|
||||||
|
- Persistenz in `%APPDATA%\IntuneAppManager-Web\settings.json`
|
||||||
|
- **WCAG-Kontrast-Algorithmus** wählt automatisch Text-/Icon-Farbe (Schwarz/Weiß) je nach gewähltem Tint
|
||||||
|
- **Surface-Shade-Algorithmus** leitet harmonische innere Surfaces aus der gewählten Detail-Panel-Farbe ab
|
||||||
|
|
||||||
|
### Neu — Branding
|
||||||
|
|
||||||
|
- **Default-Logo** `application.png` im Projekt-Root wird angezeigt, sofern kein eigenes hochgeladen wurde
|
||||||
|
- **Brand-Farbe** ist eigene Konzeptfarbe (separat von Available) — Logo-Hintergrund und Stepper-Indikator
|
||||||
|
- **Avatar** im Header: Bootstrap `person-circle`-Icon, eingefärbt in Brand-Primary, mit pulsierendem grünem Online-Dot
|
||||||
|
- **Logout** als Türsymbol mit Pfeil
|
||||||
|
|
||||||
|
### Neu — Header
|
||||||
|
|
||||||
|
- **Cog-Icon** für Einstellungen
|
||||||
|
- **Fragezeichen-Icon** für Hilfe
|
||||||
|
- **Theme-Toggle** als Sun/Mond-Slider
|
||||||
|
- **Avatar** mit Tooltip (vollständiger UPN)
|
||||||
|
- **Logout-Icon** (statt Text-Button)
|
||||||
|
|
||||||
|
### Neu — Hilfe-System
|
||||||
|
|
||||||
|
- **Hilfe-Modal** (1320 px breit) mit:
|
||||||
|
- TOC-Sidebar (auto-generiert aus H2-Headings)
|
||||||
|
- Aktiver Eintrag wird via Scroll-Position highlighted
|
||||||
|
- Inhalt aus `www/help.md` (Markdown, jederzeit ohne Code-Änderung anpassbar)
|
||||||
|
- Inhalt: Schnellstart, Hauptworkflow, Header, Spalten, App-Detail, Einstellungen, Einschränkungen, Tastatur, Fehlerbehebung
|
||||||
|
|
||||||
|
### Neu — Empfänger-Liste
|
||||||
|
|
||||||
|
- **Drei Modi**: Abteilungen, RPA-Gruppen, Einzel-Benutzer — frei mischbar
|
||||||
|
- **Gruppen aufklappen** (Pfeil rechts) → Mitglieder inline mit Suchfilter
|
||||||
|
- **Teams-Icon** bei jedem User → Direkt-Chat in Teams-Desktop-App
|
||||||
|
- **Externer-Link- und Members-Tab-Icons** öffnen direkt im Intune-Portal
|
||||||
|
- **Icons immer sichtbar** (vorher hover-only)
|
||||||
|
|
||||||
|
### Neu — Backend
|
||||||
|
|
||||||
|
- **Graph `$batch`-Optimierung** für App-Details: 3 Sub-Calls in 1 HTTP-Roundtrip, server-seitig parallelisiert
|
||||||
|
- **PowerShell-Module-Pre-Flight** in `Start.ps1`: prüft, prompted, installiert fehlende Module im User-Scope
|
||||||
|
- **WAM-Window-Focus-Helper**: Auth-Popup wird automatisch nach vorne geholt (Background-Runspace)
|
||||||
|
- **HTML-Apply-Report** in `%TEMP%\IntuneAppManager-Reports\` mit Erfolg/Fehler-Tabelle nach App gruppiert
|
||||||
|
- **Caches**: Apps + Groups + Membership im Server-Process. Selektive Invalidierung bei Settings-Änderungen
|
||||||
|
- **Run.cmd** als plattform-unabhängiger Launcher; PowerShell-Fenster startet minimiert
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **App-Row-Layout**: schmalere Type-/Version-Spalten, breitere Name-Spalte; Icons rechtsbündig vertikal mittig
|
||||||
|
- **Filter-Chips**: keine Pillen-Rahmen mehr, mehr Gap zwischen Chips (20 px)
|
||||||
|
- **App-Namen**: brechen jetzt um statt zu truncieren (kein `…` mehr)
|
||||||
|
- **Detail-Panel**: max 2 Spalten statt Auto-Fit, lange Inhalte als Wide-Sektion
|
||||||
|
- **Modal-Höhe**: max 85 vh statt 60 vh, kein Scrollen für übliche Dialog-Inhalte
|
||||||
|
- **Stepper** im Header entfernt (war redundant)
|
||||||
|
- **Default-Farben**: Brand `#27a078`, Required `#cb2a7a`, RowSelected `#71e5c4`, DetailPanel `#f7f7f7`
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **PowerShell Single-Element-Array-JSON-Bug**: `@()` am Call-Site erzwingt Array-Serialisierung (sonst wurde Single-Dep als Objekt statt Array im JSON gerendert → Frontend ignorierte sie)
|
||||||
|
- **Date-Picker Blink-Effekt** beim Klick: nativer Browser-Picker ersetzt durch flatpickr (kein Chromium-Initial-Render-Quirk mehr)
|
||||||
|
- **Date-Picker Dark-Mode**: Calendar-Icon war nicht sichtbar — durch `filter: invert(1) brightness(1.4)` korrigiert
|
||||||
|
- **WAM-Auth-Fenster** versteckt hinter anderen Fenstern → Parallel-Runspace holt es nach vorne
|
||||||
|
- **Browser-Cache**: aggressive `no-store`-Header für HTML/JS/CSS sorgen für sofortige Updates
|
||||||
|
- **Transparenz-Slider** komplett entfernt — picked color = solid background (war zuvor verwirrend)
|
||||||
|
- **Logo-Cache** zweiter Upload greift sofort (mtime als Cache-Bust-Tag)
|
||||||
|
- **Settings-Save**: Selektive Cache-Invalidierung statt aller Caches (Logo-Update reloaded nicht mehr die Apps)
|
||||||
|
- **`jumpToAppById`**: triggert jetzt den Details-Fetch (vorher: ewiger Spinner + toggleAppExpand-Konflikt → "App geht nicht auf")
|
||||||
|
- **Portal-URLs**: Group-Links auf `intune.microsoft.com/.../GroupDetailsMenuBlade/.../menuId/` korrigiert (vorher `entra.microsoft.com` ohne `/menuId/`-Suffix)
|
||||||
|
- **App-Name-Icons** vertikal mittig zur ganzen Row (nicht nur zur Name-Line)
|
||||||
|
- **Type/Version-Chips in markierter Row**: Kontrast korrekt (vorher unleserlich)
|
||||||
|
- **Stepper-Reste**: Position der Header-Actions wieder rechts
|
||||||
|
- **Connect-Endpoint**: bessere Fehlermeldung mit echter Graph-Antwort statt nur "Ungültige Anfrage"
|
||||||
|
|
||||||
|
### Entfernt
|
||||||
|
|
||||||
|
- **Workflow-Stepper** im Header (Verbinden → App-Zuweisungen → Apps zuweisen → Ausführen)
|
||||||
|
- **Transparenz-Slider** in den Theme-Color-Settings
|
||||||
|
- **`members.html`-Standalone-Seite**: ersetzt durch direkten Intune-Portal-Link
|
||||||
|
- **Native Date-Inputs** (`type="date"`): ersetzt durch flatpickr
|
||||||
|
- **`color-scheme: light`**-Workaround für Date-Picker (nicht mehr nötig nach flatpickr-Integration)
|
||||||
|
|
||||||
|
### Bundled Libraries
|
||||||
|
|
||||||
|
- `marked v15.0.12` (MIT) — Markdown-Renderer für App-Beschreibung + Help-Page
|
||||||
|
- `flatpickr v4.6.13` (MIT) — Date-Range-Filter
|
||||||
|
|
||||||
|
### Technisch
|
||||||
|
|
||||||
|
- **PowerShell-Server**: HttpListener, Single-Thread Request-Loop, ausreichend für Single-Admin-Tool
|
||||||
|
- **Frontend**: Vanilla JS + CSS, keine Frameworks
|
||||||
|
- **Persistenz**: JSON-Datei in `%APPDATA%\IntuneAppManager-Web\settings.json`
|
||||||
|
- **Cache-Strategy**: Apps + Gruppen + GroupMembers im PS-Prozess; Theme + Logo per CSS-Variable zur Laufzeit überschrieben
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Intune Manager – Web Edition
|
||||||
|
|
||||||
|
Modernes Web-Frontend fuer den Intune Manager.
|
||||||
|
PowerShell startet einen lokalen HTTP-Server, der das Frontend ausliefert
|
||||||
|
und die Microsoft Graph-Aufrufe ausfuehrt.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Windows mit PowerShell 5.1 oder PowerShell 7+
|
||||||
|
- Modul `Microsoft.Graph.Authentication` (wird beim ersten Connect benoetigt):
|
||||||
|
```powershell
|
||||||
|
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
|
||||||
|
```
|
||||||
|
- Browser (Edge, Chrome, Firefox)
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\Start.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionen:
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|---------------|----------|-----------------------------------------------------------|
|
||||||
|
| `-Port` | `8077` | Port des lokalen HTTP-Servers |
|
||||||
|
| `-TenantId` | — | Ueberschreibt Tenant-ID aus Settings (nur dieser Start) |
|
||||||
|
| `-ClientId` | — | Ueberschreibt Client-ID aus Settings (nur dieser Start) |
|
||||||
|
| `-NoBrowser` | aus | Browser nicht automatisch oeffnen |
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\Start.ps1 -Port 8088
|
||||||
|
.\Start.ps1 -NoBrowser
|
||||||
|
```
|
||||||
|
|
||||||
|
Beenden: `Ctrl+C` im Terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindungsmodus (Read/Write vs. Read-Only)
|
||||||
|
|
||||||
|
Der Server probiert beim Verbinden **zuerst die Read/Write-App-ID** (`clientId`).
|
||||||
|
Schlaegt diese fehl, wird automatisch auf die **Read-Only-App-ID** (`clientIdRo`) zurueckgefallen.
|
||||||
|
|
||||||
|
| Modus | Anmelde-Ergebnis | Verhalten im Frontend |
|
||||||
|
|-------------|------------------------------------------|------------------------------------------------|
|
||||||
|
| Read/Write | Normale Verbindung | Alle Funktionen verfuegbar |
|
||||||
|
| Read-Only | Fallback auf `clientIdRo` | Schreib-Buttons/Tabs werden ausgeblendet |
|
||||||
|
|
||||||
|
### Was wird im Read-Only-Modus ausgeblendet?
|
||||||
|
|
||||||
|
- App-Zuweisung (Required / Available hinzufuegen und entfernen)
|
||||||
|
- Gruppen erstellen und loeschen
|
||||||
|
- Tab „Benutzer hinzufuegen" im Group Management
|
||||||
|
- Tab „CSV-Import" im Group Management
|
||||||
|
- Session-Ausfuehren-Button
|
||||||
|
- Alle Rename-/Delete-Buttons
|
||||||
|
|
||||||
|
### Konfiguration in den Einstellungen
|
||||||
|
|
||||||
|
```
|
||||||
|
Settings → Verbindung
|
||||||
|
Client-ID (Read/Write): <Azure App-Registration mit Schreibrechten>
|
||||||
|
Client-ID (Read-Only): <Azure App-Registration nur mit Leserechten> (optional)
|
||||||
|
Scopes RW: DeviceManagementApps.ReadWrite.All ...
|
||||||
|
Scopes RO: DeviceManagementApps.Read.All ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benoetigt Graph-Berechtigungen
|
||||||
|
|
||||||
|
| Funktion | Benoetigt |
|
||||||
|
|----------------------------------|----------------------------------------------|
|
||||||
|
| Apps anzeigen | `DeviceManagementApps.Read.All` |
|
||||||
|
| Gruppen anzeigen / Mitglieder | `GroupMember.Read.All` |
|
||||||
|
| User suchen | `User.Read.All` |
|
||||||
|
| Zuweisungen aendern | `DeviceManagementApps.ReadWrite.All` |
|
||||||
|
| Gruppen-Mitglieder aendern | `GroupMember.ReadWrite.All` |
|
||||||
|
| App-Installationszaehler (Report)| `DeviceManagementManagedDevices.Read.All` ⚠ |
|
||||||
|
| Geraete-Exportliste pro App | `DeviceManagementManagedDevices.Read.All` ⚠ |
|
||||||
|
|
||||||
|
> ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App-Report nur
|
||||||
|
> Zaehler = 0 und der Geraete-Export liefert keine Eintraege.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation (Tabs)
|
||||||
|
|
||||||
|
| Tab | Beschreibung |
|
||||||
|
|--------------------|-------------------------------------------------------------|
|
||||||
|
| App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen |
|
||||||
|
| Group Management | Mitglieder anzeigen, exportieren, Benutzer hinzufuegen, CSV-Import |
|
||||||
|
| App Report | Installationszaehler pro App, Geraete-Export als CSV |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Management
|
||||||
|
|
||||||
|
### Mitglieder & Export
|
||||||
|
|
||||||
|
- Gruppe links suchen und auswaehlen
|
||||||
|
- Mitgliederliste (inkl. verschachtelte Gruppen) wird geladen
|
||||||
|
- Filter zum Einschraenken der Anzeige
|
||||||
|
- **Als CSV exportieren** laedt alle Mitglieder als `members-<Gruppe>-<Datum>.csv`
|
||||||
|
|
||||||
|
### Benutzer hinzufuegen *(nur Read/Write)*
|
||||||
|
|
||||||
|
- Benutzer per Name, UPN oder E-Mail suchen
|
||||||
|
- Mehrere Benutzer auswaehlen und gemeinsam hinzufuegen
|
||||||
|
|
||||||
|
### CSV-Import *(nur Read/Write)*
|
||||||
|
|
||||||
|
Massenhaftes Hinzufuegen von Benutzern zu einer Gruppe per CSV-Datei.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
|
||||||
|
1. Gruppe links auswaehlen
|
||||||
|
2. CSV hochladen **oder** UPNs/E-Mails direkt einfuegen (eine pro Zeile)
|
||||||
|
3. **„Benutzer aufloesen"** – Graph-Lookup via UPN/Mail → Vorschau mit ✓/✗
|
||||||
|
4. **„Gefundene importieren"** – fuegt alle gefundenen Benutzer zur Gruppe hinzu
|
||||||
|
|
||||||
|
**CSV-Format:**
|
||||||
|
|
||||||
|
Die erste Zeile wird als optionaler Header erkannt.
|
||||||
|
Spalte mit dem Namen `UserPrincipalName`, `Mail` oder `Email` wird automatisch
|
||||||
|
ausgewaehlt. Ohne Header: erste Spalte mit `@` wird verwendet.
|
||||||
|
|
||||||
|
```csv
|
||||||
|
DisplayName,UserPrincipalName,Mail
|
||||||
|
Max Mustermann,max.mustermann@firma.de,max.mustermann@firma.de
|
||||||
|
Erika Muster,erika.muster@firma.de,
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder einfach eine UPN pro Zeile:
|
||||||
|
```
|
||||||
|
max.mustermann@firma.de
|
||||||
|
erika.muster@firma.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ergebnis-Anzeige nach Import:**
|
||||||
|
|
||||||
|
| Chip / Status | Bedeutung |
|
||||||
|
|--------------------|------------------------------------------------|
|
||||||
|
| ✔ Gruen | Erfolgreich hinzugefuegt |
|
||||||
|
| ≈ Gelb | Bereits Mitglied (kein Fehler) |
|
||||||
|
| ✗ Rot | Fehler (Meldung in der Tabelle) |
|
||||||
|
| Grau | UPN nicht in Graph gefunden (nicht importiert) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Report
|
||||||
|
|
||||||
|
- **Neu laden**: laedt Installationszaehler fuer alle Apps (kann 1–2 Min. dauern)
|
||||||
|
- Tabelle sortierbar per Klick auf Spaltenheader
|
||||||
|
- Suche filtert nach App-Name, Publisher, Typ
|
||||||
|
- **Als CSV exportieren**: exportiert die gefilterte Tabelle
|
||||||
|
- **Geraete-Export** (pro Zeile): laedt alle Geraete mit Installationsstatus fuer diese App
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Intune Manager/
|
||||||
|
├── Start.ps1 Entry Point, Config, State
|
||||||
|
├── README.md diese Datei
|
||||||
|
├── src/
|
||||||
|
│ ├── Models.ps1 Settings-Schema und Defaults
|
||||||
|
│ ├── Graph.ps1 Microsoft Graph API Helpers
|
||||||
|
│ ├── Server.ps1 HTTP-Server (HttpListener)
|
||||||
|
│ ├── Router.ps1 Request-Routing
|
||||||
|
│ └── Api.ps1 REST-API-Endpoints
|
||||||
|
└── www/
|
||||||
|
├── index.html SPA-Shell
|
||||||
|
├── styles.css Dark Theme, Read-Only-Regeln
|
||||||
|
└── app.js Frontend-Logik (Vanilla JS)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST-API (intern)
|
||||||
|
|
||||||
|
| Methode | Pfad | Rechte | Zweck |
|
||||||
|
|---------|-------------------------------------------|-----------|------------------------------------------|
|
||||||
|
| GET | `/api/status` | — | Verbindungsstatus + ReadOnly-Flag |
|
||||||
|
| POST | `/api/connect` | — | Microsoft Graph verbinden |
|
||||||
|
| POST | `/api/disconnect` | — | Trennen |
|
||||||
|
| GET | `/api/groups` | Read | Abteilungs-Gruppen (`abt-hm-*`) |
|
||||||
|
| GET | `/api/groups/rpa` | Read | 3 vordefinierte RPA-Gruppen |
|
||||||
|
| GET | `/api/groups/search?q=` | Read | Gruppen suchen |
|
||||||
|
| POST | `/api/groups` | **Write** | Neue Required-Gruppe erstellen |
|
||||||
|
| POST | `/api/groups/check` | Read | Gruppen-Name pruefen |
|
||||||
|
| POST | `/api/groups/resolve-upns` | Read | UPN-Liste → UserId + DisplayName |
|
||||||
|
| GET | `/api/groups/{id}/members` | Read | Mitglieder einer Gruppe |
|
||||||
|
| GET | `/api/groups/{id}/members/export` | Read | Mitglieder als CSV |
|
||||||
|
| POST | `/api/groups/{id}/members` | **Write** | Benutzer zur Gruppe hinzufuegen |
|
||||||
|
| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer aus Gruppe entfernen |
|
||||||
|
| GET | `/api/users?q=` | Read | Benutzer suchen |
|
||||||
|
| GET | `/api/apps` | Read | Apps laden (cached) |
|
||||||
|
| POST | `/api/apps/refresh` | Read | Apps neu laden |
|
||||||
|
| GET | `/api/apps/report` | Read+MDM | Installationszaehler alle Apps |
|
||||||
|
| GET | `/api/apps/devicestatus?appId=` | Read+MDM | Geraete-Installationsstatus einer App |
|
||||||
|
| GET | `/api/membership?targets=&groupId=` | Read | Mitgliedschafts-Status pruefen |
|
||||||
|
| POST | `/api/membership/bulk` | Read | Mehrere Mitgliedschaften pruefen |
|
||||||
|
| POST | `/api/assignments/apply` | **Write** | Geplante Zuweisungen ausfuehren |
|
||||||
|
|
||||||
|
> **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- **Single-User-App** – der lokale HTTP-Server ist fuer einen Benutzer ausgelegt.
|
||||||
|
- **Auth lebt im PowerShell-Prozess** – Disconnect oder Schliessen des Terminals beendet die Session.
|
||||||
|
- **Cache** – Apps werden im Serverprozess gecacht. Refresh-Knopf laedt neu.
|
||||||
|
- **Port-Konflikt** – laeuft bereits eine Instanz, beendet `Start.ps1` sie automatisch (per CommandLine-Matching). Bei PID-4-Konflikt (http.sys) anderen Port waehlen: `.\Start.ps1 -Port 8088`.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
REM Launcher fuer Start.ps1 — funktioniert auf allen Clients, unabhaengig
|
||||||
|
REM davon ob .ps1-Dateien standardmaessig mit PowerShell oder Notepad
|
||||||
|
REM verknuepft sind. Verknuepfungen koennen direkt auf diese .cmd zeigen.
|
||||||
|
REM
|
||||||
|
REM -NoProfile : keine User-Profile-Skripte laden (schneller, sauberer)
|
||||||
|
REM -ExecutionPolicy : Bypass nur fuer diesen Prozess, keine Systemaenderung
|
||||||
|
REM -WindowStyle Minimized : PowerShell-Fenster startet minimiert (Taskleiste).
|
||||||
|
REM Browser oeffnet sich trotzdem; bei Bedarf kann das
|
||||||
|
REM PS-Fenster ueber die Taskleiste sichtbar gemacht
|
||||||
|
REM werden (z.B. fuer Live-Logs).
|
||||||
|
REM -File ... %* : Start.ps1 ausfuehren, alle Argumente durchreichen
|
||||||
|
REM
|
||||||
|
REM Beispielaufrufe:
|
||||||
|
REM Run.cmd
|
||||||
|
REM Run.cmd -Port 8088
|
||||||
|
REM Run.cmd -NoBrowser
|
||||||
|
|
||||||
|
start "Intune Manager" /min powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%~dp0Start.ps1" %*
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Intune Manager - Web Edition
|
||||||
|
# Startet einen lokalen HTTP-Server und oeffnet das moderne Web-Frontend.
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# .\Start.ps1
|
||||||
|
# .\Start.ps1 -Port 8088 -NoBrowser
|
||||||
|
# .\Start.ps1 -TenantId "..." -ClientId "..."
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[int]$Port = 8077,
|
||||||
|
# Optional: ueberschreibt die persistierten Settings nur fuer diesen Lauf.
|
||||||
|
# Wenn nicht gesetzt -> Werte aus %APPDATA%\IntuneAppManager-Web\settings.json
|
||||||
|
# (bzw. den Defaults beim Erststart) werden verwendet.
|
||||||
|
[string]$TenantId = "",
|
||||||
|
[string]$ClientId = "",
|
||||||
|
[switch]$NoBrowser,
|
||||||
|
[switch]$AutoInstall, # ohne Rueckfrage installieren
|
||||||
|
[switch]$SkipModuleCheck # Pre-Flight ueberspringen (z.B. CI)
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Pre-Flight: PowerShell-Module pruefen / installieren
|
||||||
|
# (nur User-Scope, nichts systemweites)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
function Test-RequiredModules {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[hashtable[]]$Required,
|
||||||
|
[switch]$AutoInstall
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Pruefe PowerShell-Module..." -ForegroundColor Cyan
|
||||||
|
$missing = @()
|
||||||
|
$outdated = @()
|
||||||
|
|
||||||
|
foreach ($req in $Required) {
|
||||||
|
$name = $req.Name
|
||||||
|
$minV = [version]$req.MinVersion
|
||||||
|
$available = @(Get-Module -Name $name -ListAvailable -ErrorAction SilentlyContinue)
|
||||||
|
if ($available.Count -eq 0) {
|
||||||
|
Write-Host " [FEHLT] $name (>= $minV)" -ForegroundColor Yellow
|
||||||
|
$missing += $req
|
||||||
|
} else {
|
||||||
|
$maxV = ($available | Sort-Object Version -Descending | Select-Object -First 1).Version
|
||||||
|
if ($maxV -lt $minV) {
|
||||||
|
Write-Host " [ALT] $name v$maxV (<$minV)" -ForegroundColor Yellow
|
||||||
|
$outdated += @{ Name = $name; Have = $maxV; Need = $minV }
|
||||||
|
} else {
|
||||||
|
Write-Host " [OK] $name v$maxV" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing.Count -eq 0 -and $outdated.Count -eq 0) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# NuGet-Provider sicherstellen, sonst schlaegt Install-Module schweigend fehl
|
||||||
|
$nuget = Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue
|
||||||
|
if (-not $nuget -or $nuget.Version -lt [version]"2.8.5.201") {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "NuGet-PackageProvider fehlt � wird im User-Scope nachgezogen..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -ErrorAction Stop | Out-Null
|
||||||
|
} catch {
|
||||||
|
throw "NuGet-Provider konnte nicht installiert werden: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# PSGallery als trusted markieren, sonst kommt fuer jede Install-Aktion ein Prompt
|
||||||
|
$gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue
|
||||||
|
if ($gallery -and $gallery.InstallationPolicy -ne 'Trusted') {
|
||||||
|
try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $AutoInstall) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Folgendes wird im User-Scope (CurrentUser) installiert/aktualisiert:" -ForegroundColor Cyan
|
||||||
|
foreach ($m in $missing) { Write-Host " + $($m.Name) min. $($m.MinVersion)" -ForegroundColor White }
|
||||||
|
foreach ($o in $outdated) { Write-Host " ~ $($o.Name) v$($o.Have) -> min. $($o.Need)" -ForegroundColor White }
|
||||||
|
Write-Host ""
|
||||||
|
$answer = Read-Host "Jetzt automatisch installieren? [J]a / [N]ein"
|
||||||
|
if ($answer -notmatch '^(j|J|y|Y)') {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Abbruch. Du kannst die Module manuell installieren:" -ForegroundColor Yellow
|
||||||
|
foreach ($m in $missing) { Write-Host " Install-Module $($m.Name) -Scope CurrentUser -Force" -ForegroundColor Gray }
|
||||||
|
foreach ($o in $outdated) { Write-Host " Update-Module $($o.Name) -Scope CurrentUser -Force" -ForegroundColor Gray }
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($m in $missing) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installiere $($m.Name) (CurrentUser)..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
Install-Module -Name $m.Name -MinimumVersion $m.MinVersion -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||||
|
Write-Host " -> OK" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " -> Fehler: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($o in $outdated) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Aktualisiere $($o.Name) auf min. $($o.Need)..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
Install-Module -Name $o.Name -MinimumVersion $o.Need -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||||
|
Write-Host " -> OK" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " -> Fehler: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipModuleCheck) {
|
||||||
|
$required = @(
|
||||||
|
@{ Name = "Microsoft.Graph.Authentication"; MinVersion = "2.0.0" }
|
||||||
|
)
|
||||||
|
if (-not (Test-RequiredModules -Required $required -AutoInstall:$AutoInstall)) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Server-Start abgebrochen � benoetigte Module fehlen." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quellmodule laden (Reihenfolge wichtig)
|
||||||
|
. (Join-Path $root "src/Models.ps1")
|
||||||
|
. (Join-Path $root "src/Graph.ps1")
|
||||||
|
. (Join-Path $root "src/Api.ps1")
|
||||||
|
. (Join-Path $root "src/Router.ps1")
|
||||||
|
. (Join-Path $root "src/Server.ps1")
|
||||||
|
|
||||||
|
# Settings aus Persistenz (Datei) laden, optional per Parameter ueberschreiben
|
||||||
|
$script:Settings = Read-Settings
|
||||||
|
if ($TenantId) { $script:Settings.connection.tenantId = $TenantId }
|
||||||
|
if ($ClientId) { $script:Settings.connection.clientId = $ClientId }
|
||||||
|
|
||||||
|
# Globale Konfiguration. TenantId/ClientId/Scopes spiegeln die Settings �
|
||||||
|
# Connect-Endpoint und Co. lesen weiterhin $script:Config, das nach jedem
|
||||||
|
# Settings-Save aktualisiert wird.
|
||||||
|
$script:Config = @{
|
||||||
|
TenantId = $script:Settings.connection.tenantId
|
||||||
|
ClientId = $script:Settings.connection.clientId
|
||||||
|
ClientIdRo = $script:Settings.connection.clientIdRo
|
||||||
|
Scopes = @($script:Settings.connection.scopes)
|
||||||
|
ScopesRo = @($script:Settings.connection.scopesRo)
|
||||||
|
WebRoot = (Join-Path $root "www")
|
||||||
|
ReportDir = (Join-Path $env:TEMP "IntuneAppManager-Reports")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $script:Config.ReportDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $script:Config.ReportDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Session-State (im PowerShell-Prozess gehalten)
|
||||||
|
$script:State = [pscustomobject]@{
|
||||||
|
Connected = $false
|
||||||
|
ReadOnly = $false
|
||||||
|
Account = $null
|
||||||
|
TenantId = $null
|
||||||
|
Groups = @() # abt-hm Gruppen
|
||||||
|
RpaGroups = @()
|
||||||
|
Apps = @()
|
||||||
|
Users = @{} # cache by id
|
||||||
|
GroupMembers = @{} # cache groupId -> member ids
|
||||||
|
Session = @() # geplante Zuweisungen
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:ToolVersion = "0.1.23"
|
||||||
|
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Intune Manager - Web Edition" -ForegroundColor Cyan
|
||||||
|
Write-Host " Version: $script:ToolVersion" -ForegroundColor Green
|
||||||
|
Write-Host " BUILD: $script:BuildStamp" -ForegroundColor DarkGray
|
||||||
|
Write-Host "==============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Alte Instanzen beenden: laeuft das Tool bereits, haelt der alte Prozess den
|
||||||
|
# Port (HttpListener via http.sys) � ein zweiter Start scheitert dann still und
|
||||||
|
# das alte Fenster bedient weiter mit altem Code. Darum alle anderen Start.ps1-
|
||||||
|
# Prozesse DIESES Pfads vorher beenden, damit ein Neustart wirklich neu laedt.
|
||||||
|
# ============================================================
|
||||||
|
try {
|
||||||
|
$thisScript = $PSCommandPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($thisScript)) { $thisScript = Join-Path $PSScriptRoot 'Start.ps1' }
|
||||||
|
# Nur echte Server-Instanzen treffen: per -File gestartet UND auf diesen
|
||||||
|
# Start.ps1-Pfad zeigend. So werden -Command-Prozesse (Diagnose etc.), die
|
||||||
|
# den Pfad nur als String enthalten, NICHT versehentlich beendet.
|
||||||
|
$stale = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -like "*$thisScript*") -and ($_.CommandLine -like "*-File*") })
|
||||||
|
foreach ($p in $stale) {
|
||||||
|
Write-Host " Beende alte Instanz (PID $($p.ProcessId))..." -ForegroundColor Yellow
|
||||||
|
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop }
|
||||||
|
catch { Write-Host " konnte PID $($p.ProcessId) nicht beenden: $($_.Exception.Message)" -ForegroundColor DarkYellow }
|
||||||
|
}
|
||||||
|
if ($stale.Count -gt 0) { Start-Sleep -Milliseconds 800 } # kurz warten bis der Port frei ist
|
||||||
|
} catch {
|
||||||
|
Write-Host " (Konnte alte Instanzen nicht pruefen: $($_.Exception.Message))" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = "http://localhost:$Port/"
|
||||||
|
Write-Host " URL: $url" -ForegroundColor Green
|
||||||
|
Write-Host " WebRoot: $($script:Config.WebRoot)" -ForegroundColor DarkGray
|
||||||
|
Write-Host " Reports: $($script:Config.ReportDir)" -ForegroundColor DarkGray
|
||||||
|
Write-Host " Beenden: Ctrl+C" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if (-not $NoBrowser) {
|
||||||
|
Start-Process $url
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-WebServer -Port $Port
|
||||||
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 |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 943 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
+3008
File diff suppressed because it is too large
Load Diff
+1000
File diff suppressed because it is too large
Load Diff
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# Datenklassen / Hilfen fuer Frontend-JSON
|
||||||
|
|
||||||
|
function ConvertTo-Json2 {
|
||||||
|
param([Parameter(ValueFromPipeline=$true)]$InputObject, [int]$Depth = 12)
|
||||||
|
process {
|
||||||
|
return $InputObject | ConvertTo-Json -Depth $Depth -Compress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalisiert ein potentielles Datum (DateTime, DateTimeOffset, String,
|
||||||
|
# /Date(ms)/, $null) auf ein ISO-8601-String — damit JS' new Date() es
|
||||||
|
# zuverlaessig parsen kann.
|
||||||
|
function ConvertTo-IsoDate {
|
||||||
|
param($Value)
|
||||||
|
if ($null -eq $Value) { return $null }
|
||||||
|
if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('o') }
|
||||||
|
if ($Value -is [DateTimeOffset]) { return $Value.UtcDateTime.ToString('o') }
|
||||||
|
$s = [string]$Value
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s)) { return $null }
|
||||||
|
# Microsoft-Legacy "/Date(1234567890123)/"
|
||||||
|
if ($s -match '^\/Date\((-?\d+)') {
|
||||||
|
try { return ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$matches[1])).UtcDateTime.ToString('o') } catch {}
|
||||||
|
}
|
||||||
|
# Schon ein parsbares Datum?
|
||||||
|
try { return ([DateTime]$s).ToUniversalTime().ToString('o') } catch {}
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Settings: editierbare Konfiguration, persistiert als JSON
|
||||||
|
# unter %APPDATA%\IntuneAppManager-Web\settings.json
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
function Get-SettingsPath {
|
||||||
|
$dir = Join-Path $env:APPDATA "IntuneAppManager-Web"
|
||||||
|
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||||
|
return Join-Path $dir "settings.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DefaultSettings {
|
||||||
|
# Tenant-spezifische Felder (tenantId, clientId, departments.prefix,
|
||||||
|
# rpa.groupNames) sind absichtlich leer — eine frische Installation
|
||||||
|
# zwingt den Admin durchs Setup. Generische Werte (Scopes, Naming-Schemas,
|
||||||
|
# Theme) sind branchenuebliche Startpunkte und bleiben besetzt.
|
||||||
|
return [pscustomobject]@{
|
||||||
|
connection = [pscustomobject]@{
|
||||||
|
tenantId = ""
|
||||||
|
clientId = ""
|
||||||
|
clientIdRo = ""
|
||||||
|
# DeviceManagementApps.ReadWrite.All ist Pflicht: assign (native All
|
||||||
|
# Users/Devices), PATCH (Rename) und DELETE auf mobileApps brauchen
|
||||||
|
# ReadWrite — Read.All allein liefert dort 403. Verifiziert via
|
||||||
|
# msgraph-Skill gegen den offiziellen Graph-Permission-Index.
|
||||||
|
scopes = @("Group.ReadWrite.All", "GroupMember.ReadWrite.All", "User.Read.All", "DeviceManagementApps.ReadWrite.All")
|
||||||
|
scopesRo = @("Group.Read.All", "GroupMember.Read.All", "User.Read.All", "DeviceManagementApps.Read.All")
|
||||||
|
}
|
||||||
|
departments = [pscustomobject]@{
|
||||||
|
# Ein oder mehrere Praefixe fuer den Abteilungs-Modus (linke Spalte).
|
||||||
|
# Mehrfach moeglich (z.B. "abt-hm" + "abt-extern"). Pflicht: mindestens
|
||||||
|
# ein Eintrag im Setup. Der alte Single-String 'prefix' bleibt fuer
|
||||||
|
# Rueckwaerts-Kompatibilitaet — Get-DepartmentPrefixes liest beides.
|
||||||
|
prefixes = @()
|
||||||
|
prefix = ""
|
||||||
|
}
|
||||||
|
rpa = [pscustomobject]@{
|
||||||
|
# Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis.
|
||||||
|
groupNames = @()
|
||||||
|
}
|
||||||
|
userSearch = [pscustomobject]@{
|
||||||
|
# In welchen Feldern bei der Benutzersuche gesucht wird.
|
||||||
|
# Erlaubt: displayName, userPrincipalName, mail, department.
|
||||||
|
fields = @("displayName", "userPrincipalName", "mail")
|
||||||
|
}
|
||||||
|
requiredGroupNaming = [pscustomobject]@{
|
||||||
|
# Wird beim "+ Required-Gruppe"-Workflow verwendet:
|
||||||
|
# {prefix}{slug-vom-app-name}{suffix}
|
||||||
|
prefix = "intune-win-app-"
|
||||||
|
suffix = "-required"
|
||||||
|
}
|
||||||
|
availableGroupNaming = [pscustomobject]@{
|
||||||
|
# Analog zu requiredGroupNaming — fuer "+ Available-Gruppe"-Workflow.
|
||||||
|
prefix = "intune-win-app-"
|
||||||
|
suffix = "-available"
|
||||||
|
}
|
||||||
|
branding = [pscustomobject]@{
|
||||||
|
# Logo-Dateiname relativ zum Projekt-Root (dort liegen auch
|
||||||
|
# intune.png/pmpc.png/application.png). $null = Letter "I" Fallback.
|
||||||
|
logoFile = "application.png"
|
||||||
|
}
|
||||||
|
# Vendor-Registry: jede App wird gegen diese Liste in Reihenfolge
|
||||||
|
# gepruef; erster Match gewinnt. Source-String im App-Objekt =
|
||||||
|
# vendor.displayName (sonst "Intune"). Tenants koennen Detection-
|
||||||
|
# Regeln, Portal-URLs und Blocking pro Vendor in settings.json
|
||||||
|
# ueberschreiben.
|
||||||
|
vendors = @(
|
||||||
|
[pscustomobject]@{
|
||||||
|
id = "patchmypc"
|
||||||
|
displayName = "PatchMyPC"
|
||||||
|
portalUrl = "https://portal.patchmypc.com/"
|
||||||
|
logoFile = "pmpc.png"
|
||||||
|
detection = [pscustomobject]@{
|
||||||
|
field = "commandLine" # commandLine | developer | publisher | displayName | notes | owner
|
||||||
|
match = "contains" # contains | equals | regex | startsWith
|
||||||
|
pattern = "PatchMyPC"
|
||||||
|
}
|
||||||
|
block = [pscustomobject]@{ delete = $true; rename = $true }
|
||||||
|
},
|
||||||
|
[pscustomobject]@{
|
||||||
|
id = "robopack"
|
||||||
|
displayName = "Robopack"
|
||||||
|
portalUrl = "https://app.robopack.com/"
|
||||||
|
logoFile = "robopack.png"
|
||||||
|
detection = [pscustomobject]@{
|
||||||
|
field = "developer"
|
||||||
|
match = "equals"
|
||||||
|
pattern = "Robopack"
|
||||||
|
}
|
||||||
|
block = [pscustomobject]@{ delete = $true; rename = $true }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
theme = [pscustomobject]@{
|
||||||
|
# Hauptfarben fuer Highlights. Soft/Strong/Border/Bg werden im
|
||||||
|
# Frontend per rgba() aus dem Hex abgeleitet.
|
||||||
|
colors = [pscustomobject]@{
|
||||||
|
brand = "#27a078" # Primaere Firmenfarbe: Logo-Hintergrund, Stepper-Indikator
|
||||||
|
accent = "#3b82f6" # Available-Pillen, Links, Akzent-Hover
|
||||||
|
required = "#cb2a7a" # Pink (Required-Badges)
|
||||||
|
success = "#10b981" # Bereits zugewiesen
|
||||||
|
warning = "#f59e0b" # Teilweise / Skip
|
||||||
|
error = "#ef4444" # Fehler / Destructive
|
||||||
|
rowSelected = "#71e5c4" # Hintergrund der aufgeklappten/markierten App-Zeile
|
||||||
|
detailPanel = "#f7f7f7" # Hintergrund des Details-Bereichs unter der App
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tiefer Merge: gespeicherte Werte ueberschreiben Defaults, neue Default-
|
||||||
|
# Keys werden trotzdem ergaenzt — robust gegen Settings-Schema-Erweiterungen.
|
||||||
|
function Merge-Settings {
|
||||||
|
param($Base, $Override)
|
||||||
|
if ($null -eq $Override) { return $Base }
|
||||||
|
$result = [ordered]@{}
|
||||||
|
foreach ($p in $Base.PSObject.Properties) {
|
||||||
|
$name = $p.Name
|
||||||
|
$bv = $p.Value
|
||||||
|
$ov = $null
|
||||||
|
$hasOverride = $false
|
||||||
|
if ($Override.PSObject.Properties[$name]) {
|
||||||
|
$ov = $Override.PSObject.Properties[$name].Value
|
||||||
|
$hasOverride = $true
|
||||||
|
}
|
||||||
|
if (-not $hasOverride) {
|
||||||
|
$result[$name] = $bv
|
||||||
|
} elseif ($bv -is [pscustomobject] -and $ov -is [pscustomobject]) {
|
||||||
|
$result[$name] = Merge-Settings -Base $bv -Override $ov
|
||||||
|
} else {
|
||||||
|
$result[$name] = $ov
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Zusaetzliche Properties aus Override, die nicht im Base sind, ignorieren —
|
||||||
|
# sie wuerden vom Backend ohnehin nicht gelesen.
|
||||||
|
return [pscustomobject]$result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DepartmentPrefixes {
|
||||||
|
# Zentrale Quelle fuer die Abteilungs-Praefixe. Bevorzugt das neue
|
||||||
|
# 'prefixes'-Array; faellt sonst auf den alten Single-String 'prefix'
|
||||||
|
# zurueck (Rueckwaerts-Kompatibilitaet mit existierenden settings.json).
|
||||||
|
# Liefert immer ein String-Array (getrimmt, dedupliziert, ohne leere
|
||||||
|
# Eintraege), ggf. leer wenn nichts konfiguriert ist.
|
||||||
|
param($Settings)
|
||||||
|
$out = [System.Collections.Generic.List[string]]::new()
|
||||||
|
if ($null -eq $Settings -or $null -eq $Settings.departments) { return ,$out.ToArray() }
|
||||||
|
$d = $Settings.departments
|
||||||
|
$candidates = @()
|
||||||
|
if ($d.PSObject.Properties['prefixes']) { $candidates += @($d.prefixes) }
|
||||||
|
if ($d.PSObject.Properties['prefix'] -and $d.prefix) { $candidates += @([string]$d.prefix) }
|
||||||
|
$seen = @{}
|
||||||
|
foreach ($p in $candidates) {
|
||||||
|
if ($null -eq $p) { continue }
|
||||||
|
$t = ([string]$p).Trim()
|
||||||
|
if (-not $t) { continue }
|
||||||
|
$k = $t.ToLowerInvariant()
|
||||||
|
if ($seen.ContainsKey($k)) { continue }
|
||||||
|
$seen[$k] = $true
|
||||||
|
$out.Add($t)
|
||||||
|
}
|
||||||
|
return $out.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Settings {
|
||||||
|
$path = Get-SettingsPath
|
||||||
|
$defaults = Get-DefaultSettings
|
||||||
|
if (-not (Test-Path $path)) { return $defaults }
|
||||||
|
try {
|
||||||
|
$raw = Get-Content -Path $path -Raw -Encoding UTF8
|
||||||
|
if ([string]::IsNullOrWhiteSpace($raw)) { return $defaults }
|
||||||
|
$saved = $raw | ConvertFrom-Json
|
||||||
|
return Merge-Settings -Base $defaults -Override $saved
|
||||||
|
} catch {
|
||||||
|
Write-Host "[SETTINGS] Lesen fehlgeschlagen, verwende Defaults: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
return $defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Settings {
|
||||||
|
param([Parameter(Mandatory=$true)]$Settings)
|
||||||
|
$path = Get-SettingsPath
|
||||||
|
$json = $Settings | ConvertTo-Json -Depth 10
|
||||||
|
[IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8)
|
||||||
|
Write-Host "[SETTINGS] Gespeichert: $path" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SettingsValidationErrors {
|
||||||
|
# Rudimentaere Validierung. Schwere Fehler -> Speichern ablehnen.
|
||||||
|
param($S)
|
||||||
|
$errs = @()
|
||||||
|
if (-not $S.connection.tenantId -or $S.connection.tenantId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||||
|
$errs += "Tenant ID muss eine GUID sein."
|
||||||
|
}
|
||||||
|
if (-not $S.connection.clientId -or $S.connection.clientId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||||
|
$errs += "Client ID (Read/Write) muss eine GUID sein."
|
||||||
|
}
|
||||||
|
if ($S.connection.clientIdRo -and $S.connection.clientIdRo.Trim() -and $S.connection.clientIdRo -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||||
|
$errs += "Client ID (Read Only) muss eine GUID sein (oder leer lassen)."
|
||||||
|
}
|
||||||
|
if (-not $S.connection.scopes -or @($S.connection.scopes).Count -eq 0) {
|
||||||
|
$errs += "Mindestens ein Scope erforderlich."
|
||||||
|
}
|
||||||
|
$deptPrefixes = Get-DepartmentPrefixes -Settings $S
|
||||||
|
if ($deptPrefixes.Count -eq 0) {
|
||||||
|
$errs += "Mindestens ein Abteilungs-Praefix erforderlich."
|
||||||
|
} else {
|
||||||
|
$bad = @($deptPrefixes | Where-Object { $_.Trim().Length -lt 2 })
|
||||||
|
if ($bad.Count -gt 0) { $errs += "Abteilungs-Praefixe muessen jeweils mindestens 2 Zeichen lang sein." }
|
||||||
|
}
|
||||||
|
if (-not $S.rpa.groupNames -or @($S.rpa.groupNames).Count -eq 0) {
|
||||||
|
$errs += "Mindestens eine RPA-Gruppe erforderlich."
|
||||||
|
}
|
||||||
|
$allowedUserFields = @('displayName','userPrincipalName','mail','department')
|
||||||
|
$bad = @($S.userSearch.fields | Where-Object { $_ -notin $allowedUserFields })
|
||||||
|
if ($bad.Count -gt 0) { $errs += "Unbekannte User-Search-Felder: $($bad -join ', ')" }
|
||||||
|
if (-not $S.userSearch.fields -or @($S.userSearch.fields).Count -eq 0) {
|
||||||
|
$errs += "Mindestens ein User-Such-Feld erforderlich."
|
||||||
|
}
|
||||||
|
if (-not $S.requiredGroupNaming.prefix -or -not $S.requiredGroupNaming.suffix) {
|
||||||
|
$errs += "Required-Gruppen-Naming: Praefix und Suffix erforderlich."
|
||||||
|
}
|
||||||
|
if ($S.availableGroupNaming -and (-not $S.availableGroupNaming.prefix -or -not $S.availableGroupNaming.suffix)) {
|
||||||
|
$errs += "Available-Gruppen-Naming: Praefix und Suffix erforderlich."
|
||||||
|
}
|
||||||
|
# Vendor-Registry: jeder Eintrag muss id + detection mit field/match/pattern haben.
|
||||||
|
if ($null -ne $S.vendors) {
|
||||||
|
$allowedFields = @('commandLine','developer','publisher','displayName','notes','owner')
|
||||||
|
$allowedMatches = @('contains','equals','regex','startsWith')
|
||||||
|
$seenIds = @{}
|
||||||
|
foreach ($v in @($S.vendors)) {
|
||||||
|
if (-not $v.id -or [string]::IsNullOrWhiteSpace($v.id)) { $errs += "Vendor: 'id' erforderlich."; continue }
|
||||||
|
if ($seenIds.ContainsKey($v.id)) { $errs += "Vendor '$($v.id)': id ist nicht eindeutig."; continue }
|
||||||
|
$seenIds[$v.id] = $true
|
||||||
|
if (-not $v.displayName) { $errs += "Vendor '$($v.id)': displayName erforderlich." }
|
||||||
|
if (-not $v.detection) { $errs += "Vendor '$($v.id)': detection-Block fehlt."; continue }
|
||||||
|
if ($v.detection.field -notin $allowedFields) { $errs += "Vendor '$($v.id)': detection.field muss eines von $($allowedFields -join '|') sein." }
|
||||||
|
if ($v.detection.match -notin $allowedMatches) { $errs += "Vendor '$($v.id)': detection.match muss eines von $($allowedMatches -join '|') sein." }
|
||||||
|
if (-not $v.detection.pattern -or [string]::IsNullOrWhiteSpace($v.detection.pattern)) { $errs += "Vendor '$($v.id)': detection.pattern erforderlich." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Theme-Farben sind Hex-Strings (#RGB oder #RRGGBB)
|
||||||
|
if ($S.theme -and $S.theme.colors) {
|
||||||
|
foreach ($key in @('brand','accent','required','success','warning','error','rowSelected','detailPanel')) {
|
||||||
|
$val = $S.theme.colors.$key
|
||||||
|
if ($val -and $val -notmatch '^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') {
|
||||||
|
$errs += "Farbe '$key' ist kein gueltiger Hex-Wert."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $errs
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-AssignmentItem {
|
||||||
|
param(
|
||||||
|
[string]$AppId,
|
||||||
|
[string]$AppName,
|
||||||
|
[string]$AppType,
|
||||||
|
[string]$Type, # "Available" | "Required"
|
||||||
|
[string]$GroupId,
|
||||||
|
[string]$GroupName
|
||||||
|
)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Id = [guid]::NewGuid().ToString()
|
||||||
|
AppId = $AppId
|
||||||
|
AppName = $AppName
|
||||||
|
AppType = $AppType
|
||||||
|
Type = $Type
|
||||||
|
GroupId = $GroupId
|
||||||
|
GroupName = $GroupName
|
||||||
|
AddedAt = (Get-Date).ToString("o")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# HTTP-Router: leitet Requests an statische Dateien oder API-Endpoints weiter.
|
||||||
|
|
||||||
|
function Invoke-Router {
|
||||||
|
param([Parameter(Mandatory=$true)]$Context)
|
||||||
|
|
||||||
|
$request = $Context.Request
|
||||||
|
$path = $request.Url.AbsolutePath.TrimEnd('/')
|
||||||
|
if ([string]::IsNullOrEmpty($path)) { $path = "/" }
|
||||||
|
$method = $request.HttpMethod
|
||||||
|
|
||||||
|
$isApi = $path.StartsWith("/api/")
|
||||||
|
if ($isApi) {
|
||||||
|
Write-Host (">>> [{0}] {1} {2}" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path) -ForegroundColor DarkCyan
|
||||||
|
}
|
||||||
|
$reqStart = Get-Date
|
||||||
|
|
||||||
|
# API-Endpoints
|
||||||
|
if ($isApi) {
|
||||||
|
$body = $null
|
||||||
|
if ($method -in @("POST","PUT","PATCH")) {
|
||||||
|
$body = Read-RequestBody -Context $Context
|
||||||
|
}
|
||||||
|
$query = @{}
|
||||||
|
foreach ($key in $request.QueryString.AllKeys) {
|
||||||
|
if ($null -ne $key) { $query[$key] = $request.QueryString[$key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = Invoke-ApiHandler -Path $path -Method $method -Body $body -Query $query
|
||||||
|
$handlerMs = [int]((Get-Date) - $reqStart).TotalMilliseconds
|
||||||
|
if ($null -eq $result) {
|
||||||
|
Send-JsonResponse -Context $Context -StatusCode 404 -Body @{ error = "Unknown endpoint: $method $path" }
|
||||||
|
Write-Host ("<<< [{0}] {1} {2} -> 404 ({3}ms)" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path, $handlerMs) -ForegroundColor DarkYellow
|
||||||
|
} else {
|
||||||
|
$statusCode = 200
|
||||||
|
if ($result -is [hashtable] -and $result.ContainsKey('__status')) {
|
||||||
|
$statusCode = $result['__status']
|
||||||
|
$result.Remove('__status')
|
||||||
|
}
|
||||||
|
Send-JsonResponse -Context $Context -StatusCode $statusCode -Body $result
|
||||||
|
$totalMs = [int]((Get-Date) - $reqStart).TotalMilliseconds
|
||||||
|
Write-Host ("<<< [{0}] {1} {2} -> {3} (handler={4}ms total={5}ms)" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path, $statusCode, $handlerMs, $totalMs) -ForegroundColor DarkCyan
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$msg = $_.Exception.Message
|
||||||
|
# Client-Disconnects (AbortController) sind harmlos und werden nicht geloggt
|
||||||
|
if ($msg -match "Netzwerkname.*nicht.*verfügbar|connection was forcibly closed|aborted by the software|response has been submitted") {
|
||||||
|
Write-Host " [client disconnect] $path" -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
Write-Host "<<< [API ERROR] $path -> $msg" -ForegroundColor Red
|
||||||
|
Write-Host " Stack: $($_.ScriptStackTrace)" -ForegroundColor DarkRed
|
||||||
|
try {
|
||||||
|
Send-JsonResponse -Context $Context -StatusCode 500 -Body @{
|
||||||
|
error = $msg
|
||||||
|
stack = $_.ScriptStackTrace
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assets aus dem Projekt-Root (Logos etc.)
|
||||||
|
if ($path.StartsWith("/assets/")) {
|
||||||
|
$name = $path.Substring("/assets/".Length)
|
||||||
|
$name = $name -replace "[^a-zA-Z0-9._-]", ""
|
||||||
|
$rootDir = Split-Path -Parent $script:Config.WebRoot
|
||||||
|
$file = Join-Path $rootDir $name
|
||||||
|
# Branding-Logos NIE cachen — sonst sieht der User nach dem Upload
|
||||||
|
# weiter die alte Version, auch wenn die URL gleich bleibt.
|
||||||
|
$extraHeaders = $null
|
||||||
|
if ($name -like 'branding-logo.*') {
|
||||||
|
$extraHeaders = @{ 'Cache-Control' = 'no-store, max-age=0' }
|
||||||
|
}
|
||||||
|
Send-FileResponse -Context $Context -FilePath $file -ExtraHeaders $extraHeaders
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reports (HTML-Reports werden in Temp gespeichert)
|
||||||
|
if ($path.StartsWith("/reports/")) {
|
||||||
|
$name = $path.Substring("/reports/".Length)
|
||||||
|
$name = $name -replace "[^a-zA-Z0-9._-]", ""
|
||||||
|
$file = Join-Path $script:Config.ReportDir $name
|
||||||
|
Send-FileResponse -Context $Context -FilePath $file
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statische Dateien aus www/
|
||||||
|
$relativePath = if ($path -eq "/") { "index.html" } else { $path.TrimStart('/') }
|
||||||
|
$relativePath = $relativePath -replace '\.\.', '' # path traversal blocken
|
||||||
|
$file = Join-Path $script:Config.WebRoot $relativePath
|
||||||
|
|
||||||
|
if (Test-Path $file -PathType Leaf) {
|
||||||
|
Send-FileResponse -Context $Context -FilePath $file
|
||||||
|
} else {
|
||||||
|
# SPA fallback - alle nicht erkannten Pfade auf index.html
|
||||||
|
Send-FileResponse -Context $Context -FilePath (Join-Path $script:Config.WebRoot "index.html")
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
# Lokaler HTTP-Server (System.Net.HttpListener)
|
||||||
|
# Single-threaded request loop - reicht fuer einen Admin-Nutzer.
|
||||||
|
|
||||||
|
function Start-WebServer {
|
||||||
|
param([int]$Port = 8077)
|
||||||
|
|
||||||
|
$listener = New-Object System.Net.HttpListener
|
||||||
|
$prefix = "http://localhost:$Port/"
|
||||||
|
$listener.Prefixes.Add($prefix)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$listener.Start()
|
||||||
|
} catch {
|
||||||
|
Write-Host "Server konnte nicht gestartet werden auf $prefix" -ForegroundColor Red
|
||||||
|
Write-Host "Moeglicherweise ist der Port belegt oder es fehlen Rechte." -ForegroundColor Red
|
||||||
|
Write-Host "Tipp: anderen Port mit -Port 8088 versuchen, oder als Admin starten." -ForegroundColor Yellow
|
||||||
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Server laeuft. Warte auf Requests..." -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
while ($listener.IsListening) {
|
||||||
|
try {
|
||||||
|
$context = $listener.GetContext() # blockiert
|
||||||
|
try {
|
||||||
|
Invoke-Router -Context $context
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
try {
|
||||||
|
Send-JsonResponse -Context $context -StatusCode 500 -Body @{
|
||||||
|
error = $_.Exception.Message
|
||||||
|
stack = $_.ScriptStackTrace
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch [System.Net.HttpListenerException] {
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
Write-Host "[FATAL] $_" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$listener.Stop()
|
||||||
|
$listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-JsonResponse {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]$Context,
|
||||||
|
[int]$StatusCode = 200,
|
||||||
|
$Body = $null
|
||||||
|
)
|
||||||
|
$response = $Context.Response
|
||||||
|
try {
|
||||||
|
$response.StatusCode = $StatusCode
|
||||||
|
$response.ContentType = "application/json; charset=utf-8"
|
||||||
|
$response.Headers.Add("Cache-Control", "no-store")
|
||||||
|
} catch {
|
||||||
|
# Headers schon gesendet — Client wahrscheinlich schon weg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$json = if ($null -eq $Body) { "{}" } else { $Body | ConvertTo-Json -Depth 12 -Compress }
|
||||||
|
$sw.Stop()
|
||||||
|
if ($sw.ElapsedMilliseconds -gt 100) {
|
||||||
|
Write-Host " [JSON] Serialize $($json.Length) bytes in $($sw.ElapsedMilliseconds)ms (ACHTUNG > 100ms)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||||
|
try {
|
||||||
|
$response.ContentLength64 = $bytes.Length
|
||||||
|
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||||
|
$response.OutputStream.Close()
|
||||||
|
} catch [System.Net.HttpListenerException], [System.IO.IOException], [System.ObjectDisposedException] {
|
||||||
|
# Client hat die Verbindung abgebrochen (AbortController, Tab geschlossen, etc.) — irrelevant
|
||||||
|
} catch {
|
||||||
|
# Andere Fehler still mitloggen
|
||||||
|
Write-Host " [WRITE] Response-Schreiben fehlgeschlagen: $($_.Exception.GetType().Name): $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-FileResponse {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]$Context,
|
||||||
|
[Parameter(Mandatory=$true)][string]$FilePath,
|
||||||
|
[string]$ContentType = $null,
|
||||||
|
[hashtable]$ExtraHeaders = $null
|
||||||
|
)
|
||||||
|
$response = $Context.Response
|
||||||
|
if (-not (Test-Path $FilePath)) {
|
||||||
|
$response.StatusCode = 404
|
||||||
|
$response.OutputStream.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (-not $ContentType) {
|
||||||
|
$ContentType = switch ([IO.Path]::GetExtension($FilePath).ToLower()) {
|
||||||
|
".html" { "text/html; charset=utf-8" }
|
||||||
|
".js" { "application/javascript; charset=utf-8" }
|
||||||
|
".css" { "text/css; charset=utf-8" }
|
||||||
|
".json" { "application/json; charset=utf-8" }
|
||||||
|
".svg" { "image/svg+xml" }
|
||||||
|
".png" { "image/png" }
|
||||||
|
".jpg" { "image/jpeg" }
|
||||||
|
".jpeg" { "image/jpeg" }
|
||||||
|
".webp" { "image/webp" }
|
||||||
|
".gif" { "image/gif" }
|
||||||
|
".ico" { "image/x-icon" }
|
||||||
|
".woff2"{ "font/woff2" }
|
||||||
|
".md" { "text/markdown; charset=utf-8" }
|
||||||
|
default { "application/octet-stream" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
||||||
|
$response.ContentType = $ContentType
|
||||||
|
|
||||||
|
# Standard-Cache-Header: HTML/JS/CSS NICHT cachen (single-user Dev-Tool,
|
||||||
|
# Performance-Verlust irrelevant, dafuer immer aktueller Code im Browser).
|
||||||
|
# Bilder/Fonts cachen aber muessen — sonst flackert das Logo bei jedem Klick.
|
||||||
|
$ext = [IO.Path]::GetExtension($FilePath).ToLower()
|
||||||
|
$cacheCtl = if ($ext -in @('.html','.htm','.js','.css','.json')) {
|
||||||
|
'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
} else {
|
||||||
|
'no-cache, must-revalidate'
|
||||||
|
}
|
||||||
|
if ($ExtraHeaders -and $ExtraHeaders.ContainsKey('Cache-Control')) {
|
||||||
|
$cacheCtl = $ExtraHeaders['Cache-Control']
|
||||||
|
}
|
||||||
|
$response.Headers.Add('Cache-Control', $cacheCtl)
|
||||||
|
|
||||||
|
if ($ExtraHeaders) {
|
||||||
|
foreach ($k in $ExtraHeaders.Keys) {
|
||||||
|
if ($k -eq 'Cache-Control') { continue } # bereits gesetzt
|
||||||
|
try { $response.Headers.Add($k, [string]$ExtraHeaders[$k]) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response.ContentLength64 = $bytes.Length
|
||||||
|
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||||
|
$response.OutputStream.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-RequestBody {
|
||||||
|
param([Parameter(Mandatory=$true)]$Context)
|
||||||
|
$request = $Context.Request
|
||||||
|
if (-not $request.HasEntityBody) { return $null }
|
||||||
|
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
||||||
|
$body = $reader.ReadToEnd()
|
||||||
|
$reader.Close()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($body)) { return $null }
|
||||||
|
# -AsHashtable gibt es erst ab PowerShell 6 — Windows PowerShell 5.1 kennt es nicht.
|
||||||
|
# Die Endpoints behandeln sowohl Hashtable als auch PSCustomObject korrekt.
|
||||||
|
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||||||
|
return ($body | ConvertFrom-Json -AsHashtable)
|
||||||
|
}
|
||||||
|
return ($body | ConvertFrom-Json)
|
||||||
|
}
|
||||||
+5025
File diff suppressed because it is too large
Load Diff
+324
@@ -0,0 +1,324 @@
|
|||||||
|
# Intune Manager — Hilfe
|
||||||
|
|
||||||
|
Web-Frontend für Microsoft Intune. Lädt Apps und Gruppen aus deinem Tenant über Microsoft Graph und macht das Verwalten von Zuweisungen schneller als im Intune-Portal selbst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart in 5 Schritten
|
||||||
|
|
||||||
|
1. **Verbinden** — oben rechts *Anmelden* klicken → Microsoft-Anmeldefenster bestätigen.
|
||||||
|
2. **Tab wählen** — Topbar: *App Management* (Standard), *Group Management* oder *App Report*.
|
||||||
|
3. **Empfänger wählen** (App Management) — linke Spalte: Abteilung, RPA oder Benutzer markieren.
|
||||||
|
4. **Zuweisung sammeln** — bei einer App auf **Available** oder **Required** klicken.
|
||||||
|
5. **Ausführen** — rechts unten *Zuweisungen ausführen*. HTML-Report öffnet sich automatisch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Der Header enthält drei Tabs:
|
||||||
|
|
||||||
|
| Tab | Funktion |
|
||||||
|
|---|---|
|
||||||
|
| **App Management** | Haupt-Workflow: Apps laden, Gruppen zuweisen und entfernen |
|
||||||
|
| **Group Management** | Mitglieder anzeigen, exportieren, hinzufügen, CSV-Import |
|
||||||
|
| **App Report** | Installationszähler pro App, Geräte-Export als CSV |
|
||||||
|
|
||||||
|
> Im **Read-Only-Modus** (Fallback auf eingeschränkte App-ID) sind die Tabs *Benutzer hinzufügen* und *CSV-Import* ausgeblendet. Alle Schreib-Buttons verschwinden automatisch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Management — der Hauptworkflow
|
||||||
|
|
||||||
|
### Das mentale Modell
|
||||||
|
|
||||||
|
Das Tool ändert **nicht die App-Zuweisungen selbst** — die Liste der Available/Required-Gruppen einer App bleibt unverändert. Stattdessen ändert es die **Mitgliedschaften in genau diesen Gruppen**. Beispiel:
|
||||||
|
|
||||||
|
- App *Beispiel-App* hat die Required-Gruppe `intune-win-app-<slug>-required` zugewiesen.
|
||||||
|
- Du wählst links die Abteilung `<dept-prefix>-team-controlling`.
|
||||||
|
- Klick auf die *Required*-Pille → Operation wandert in die Session.
|
||||||
|
- Beim Ausführen wird `<dept-prefix>-team-controlling` als Mitglied in der Required-Gruppe aufgenommen.
|
||||||
|
- Intune zieht beim nächsten Sync alle Mitglieder als Required-Targets.
|
||||||
|
|
||||||
|
### Schritt-für-Schritt
|
||||||
|
|
||||||
|
1. **Empfänger markieren** (linke Spalte) — Modi mischbar: Abteilungen + RPA + Benutzer gleichzeitig. Die *Pinned-Targets*-Leiste zeigt alles Aktive.
|
||||||
|
2. **Apps durchgehen** (mittlere Spalte) — Filter oder Suchfeld nutzen.
|
||||||
|
3. **Available- oder Required-Button klicken**:
|
||||||
|
- *Eine Gruppe*: Empfänger direkt zur Session hinzufügen.
|
||||||
|
- *Mehrere Gruppen* (Count-Badge): Picker öffnet sich.
|
||||||
|
- *Keine Gruppe* dieses Typs: Button deaktiviert.
|
||||||
|
4. **Session prüfen** (rechte Spalte) — alle gesammelten Vorgänge als Vorschau.
|
||||||
|
5. **Ausführen** — rechts unten. Report öffnet automatisch.
|
||||||
|
|
||||||
|
### Pillen-Farben
|
||||||
|
|
||||||
|
| Optik | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| Neutral | Standard — kein Empfänger gewählt oder noch nicht geprüft |
|
||||||
|
| **Grüner Rahmen + Häkchen** | Alle Empfänger bereits Mitglied |
|
||||||
|
| **Gelber Rahmen + halbes Häkchen** | Teilweise Mitglied |
|
||||||
|
| **Schwarz gefüllt + Count-Badge** | Aktiv in der Session (Toggle: erneuter Klick entfernt) |
|
||||||
|
| Kursiv, ausgegraut | Nur native Zuweisung (All Users/Devices) — keine Gruppe änderbar |
|
||||||
|
|
||||||
|
### Gruppen aus App entfernen (−R / −A)
|
||||||
|
|
||||||
|
Jede App-Zeile zeigt rechts **−R** (Required entfernen) und **−A** (Available entfernen).
|
||||||
|
|
||||||
|
- Klick auf −R / −A → Picker mit allen Gruppen dieses Typs → Gruppe auswählen → sofortiges Löschen der Zuweisung (nach Bestätigung).
|
||||||
|
- Bei nativen Zuweisungen (All Users/All Devices) erscheint stattdessen der Mülleimer-Icon direkt im Detail-Panel.
|
||||||
|
|
||||||
|
### Plus-Button — Zuweisung hinzufügen
|
||||||
|
|
||||||
|
Modal mit 6 Optionen:
|
||||||
|
|
||||||
|
**REQUIRED** — Neue Gruppe / All Users / All Devices
|
||||||
|
**AVAILABLE** — Neue Gruppe / All Users / All Devices
|
||||||
|
|
||||||
|
Bereits aktive native Kombinationen werden **ausgegraut** (verhindert Duplikate).
|
||||||
|
|
||||||
|
### Die Session (rechte Spalte)
|
||||||
|
|
||||||
|
Pro Eintrag: App-Name + Gruppen-Name + Empfänger-Tag + A/R-Badge + X-Entfernen.
|
||||||
|
Darunter Gesamtzahl — der Apply-Button zeigt diese Zahl.
|
||||||
|
|
||||||
|
### Ausführen und Report
|
||||||
|
|
||||||
|
- Member-Add pro Session-Eintrag
|
||||||
|
- Native Targets werden übersprungen
|
||||||
|
- Bereits-Mitglied = „OK" — kein Fehler
|
||||||
|
- HTML-Report in `%TEMP%\IntuneAppManager-Reports\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Management
|
||||||
|
|
||||||
|
### Mitglieder & Export
|
||||||
|
|
||||||
|
- Gruppe links suchen und auswählen
|
||||||
|
- Mitgliederliste inkl. verschachtelter Gruppen
|
||||||
|
- Filter zum Einschränken der Anzeige
|
||||||
|
- **Als CSV exportieren** → `members-<Gruppe>-<Datum>.csv`
|
||||||
|
|
||||||
|
### Benutzer hinzufügen *(nur Read/Write)*
|
||||||
|
|
||||||
|
- Benutzer per Name, UPN oder E-Mail suchen
|
||||||
|
- Mehrere auswählen und gemeinsam zur gewählten Gruppe hinzufügen
|
||||||
|
|
||||||
|
### CSV-Import *(nur Read/Write)*
|
||||||
|
|
||||||
|
Massenimport von Benutzern per CSV-Datei oder direkter Eingabe.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
1. Gruppe links auswählen
|
||||||
|
2. CSV hochladen **oder** UPNs/E-Mails einfügen (eine pro Zeile)
|
||||||
|
3. **„Benutzer auflösen"** → Graph-Lookup → Vorschau mit ✓/✗
|
||||||
|
4. **„Gefundene importieren"** → Benutzer werden zur Gruppe hinzugefügt
|
||||||
|
|
||||||
|
**CSV-Format:** Header-Erkennung für `UserPrincipalName`, `Mail`, `Email`, `UPN`.
|
||||||
|
Ohne Header: erste Spalte mit `@` wird verwendet. Quoted fields und `,`/`;`/Tab als Trennzeichen werden unterstützt.
|
||||||
|
|
||||||
|
**Ergebnis-Banner nach Import:**
|
||||||
|
|
||||||
|
| Chip | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| ✔ Grün | Erfolgreich hinzugefügt |
|
||||||
|
| ≈ Gelb | Bereits Mitglied |
|
||||||
|
| ✗ Rot | Fehler (Meldung in der Tabelle) |
|
||||||
|
| Grau | UPN in Graph nicht gefunden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Report
|
||||||
|
|
||||||
|
- **Neu laden** — lädt Installationszähler für alle Apps (kann 1–2 Min. dauern)
|
||||||
|
- Tabelle sortierbar per Klick auf Spaltenheader (▲/▼)
|
||||||
|
- Suchfeld filtert nach Name, Publisher, Typ
|
||||||
|
- **Als CSV exportieren** — exportiert die gefilterte Tabelle
|
||||||
|
- **Geräte-Export** (Button pro Zeile) — lädt alle Geräte mit Installationsstatus für diese App als CSV
|
||||||
|
|
||||||
|
> Zählwerte erfordern die Berechtigung `DeviceManagementManagedDevices.Read.All` in der Azure App-Registration. Fehlt sie, zeigen alle Spalten 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
| Element | Funktion |
|
||||||
|
|---|---|
|
||||||
|
| **Logo** | Kann in den Einstellungen durch eigenes Firmenlogo ersetzt werden |
|
||||||
|
| **App Management / Group Management / App Report** | Haupt-Navigation |
|
||||||
|
| **Einstellungen** (Zahnrad) | Tenant/Client-ID, Naming, Farben, Logo |
|
||||||
|
| **Hilfe** (Fragezeichen) | Diese Seite |
|
||||||
|
| **Theme-Toggle** | Light/Dark-Mode — wird im LocalStorage gemerkt |
|
||||||
|
| **Avatar** | Angemeldeter Account — Mouseover zeigt vollen UPN; grüner Punkt = verbunden |
|
||||||
|
| **Logout** | Trennt die Graph-Verbindung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read-Only-Modus
|
||||||
|
|
||||||
|
Ist nur eine Read-Only-App-ID konfiguriert (oder schlägt die RW-Anmeldung fehl), schaltet das Tool automatisch in den **Read-Only-Modus**:
|
||||||
|
|
||||||
|
- Toast-Meldung „(Read Only)" beim Verbinden
|
||||||
|
- Alle Schreib-Buttons/Tabs werden ausgeblendet (`body.read-only`-CSS-Klasse)
|
||||||
|
- Sichtbar bleibt: App-Liste, Mitglieder-Ansicht, Export-Funktionen, App Report
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linke Spalte — Empfänger
|
||||||
|
|
||||||
|
Drei Modi via Segment-Switch:
|
||||||
|
|
||||||
|
- **Abteilung** — Gruppen mit konfigurierten Präfixen (mehrere möglich)
|
||||||
|
- **RPA** — explizit in den Einstellungen gelistete Gruppen
|
||||||
|
- **Benutzer** — Live-Suche über Graph (min. 2 Zeichen)
|
||||||
|
|
||||||
|
**Gruppen aufklappen** → Mitglieder inline → *Benutzer übernehmen* in den User-Modus.
|
||||||
|
|
||||||
|
**Icons pro Eintrag:**
|
||||||
|
|
||||||
|
| Icon | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Externer Link | Gruppe/User im Intune-Portal öffnen |
|
||||||
|
| Zwei Personen | Mitglieder-Ansicht im Intune-Portal |
|
||||||
|
| Teams-T | Direkt-Chat starten (Teams-Desktop) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mittlere Spalte — Apps
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
|
||||||
|
| Filter | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| Suchfeld | Volltext im App-Namen |
|
||||||
|
| Dropdown | Preset: Alle / Ohne Zuweisungen / Nur Available / Nur Required / Mehrere / All Users / All Devices / Intune / pro Vendor |
|
||||||
|
| Bereits Gruppenmitglied | Nur Apps wo Empfänger schon Mitglied ist |
|
||||||
|
| All Users/Devices ausblenden | Native Zuweisungen rausnehmen |
|
||||||
|
| Geändert (Datumsrange) | Nach Last-Modified-Datum filtern |
|
||||||
|
|
||||||
|
### App-Zeile
|
||||||
|
|
||||||
|
1. Status-Indikator (Häkchen)
|
||||||
|
2. App-Name + Hersteller → Klick öffnet Detail-Panel
|
||||||
|
3. Rename-Icon (Stift) / Lock bei Vendor-Apps
|
||||||
|
4. Source-Logo (Intune, PatchMyPC, Robopack, …)
|
||||||
|
5. Typ-Chip
|
||||||
|
6. Version
|
||||||
|
7. **Available**- und **Required**-Buttons mit Count
|
||||||
|
8. **−R** (Required-Gruppe entfernen) und **−A** (Available-Gruppe entfernen)
|
||||||
|
9. **Plus-Button** — Zuweisung hinzufügen
|
||||||
|
10. **Delete-Icon** — App löschen (bei Vendor-Apps: Lock)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-Detail (Klick auf App-Namen)
|
||||||
|
|
||||||
|
| Sektion | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Zuweisungen | Available + Required mit Aktionen (Link, Mitglieder, Löschen) |
|
||||||
|
| Allgemein | Hersteller, Version, Typ, Min-OS |
|
||||||
|
| Beschreibung | Markdown-gerendert |
|
||||||
|
| Installation | Datei, Install-/Uninstall-Befehl (Copy-Icon) |
|
||||||
|
| Detection | Registry, Datei, MSI-Code, PowerShell |
|
||||||
|
| Return-Codes | Exit-Codes mit Typ |
|
||||||
|
| Meta | Erstellt + Geändert |
|
||||||
|
| Installationen | Geräte + Benutzer × Installiert/Fehler/Pending/N/A mit Erfolgsquote |
|
||||||
|
| Abhängigkeiten | SVG-Flow-Diagramm + Klartext; Klick auf Knoten springt zur Ziel-App |
|
||||||
|
| Supersedence | Was diese App ersetzt (update / replace) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
Persistiert in `%APPDATA%\IntuneAppManager-Web\settings.json`.
|
||||||
|
|
||||||
|
| Sektion | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Verbindung | Tenant-ID, Client-ID (RW), Client-ID (RO), Scopes RW + RO |
|
||||||
|
| Abteilungs-Gruppen | Präfixe (eine pro Zeile) |
|
||||||
|
| RPA-Gruppen | Explizite Gruppennamen |
|
||||||
|
| Benutzer-Suche | Felder (DisplayName / UPN / Mail / Department) |
|
||||||
|
| Required-Naming | Präfix + Suffix |
|
||||||
|
| Available-Naming | Präfix + Suffix |
|
||||||
|
| Branding | Logo-Upload (PNG/JPG/SVG/WEBP/GIF, max. 2 MB) |
|
||||||
|
| Farben | 8 Akzentfarben + Standardfarben wiederherstellen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benötigte Graph-Berechtigungen
|
||||||
|
|
||||||
|
| Funktion | Berechtigung |
|
||||||
|
|---|---|
|
||||||
|
| Apps anzeigen | `DeviceManagementApps.Read.All` |
|
||||||
|
| Gruppen / Mitglieder anzeigen | `GroupMember.Read.All` |
|
||||||
|
| Benutzer suchen | `User.Read.All` |
|
||||||
|
| Zuweisungen ändern | `DeviceManagementApps.ReadWrite.All` |
|
||||||
|
| Gruppen-Mitglieder ändern | `GroupMember.ReadWrite.All` |
|
||||||
|
| App Report / Geräte-Export | `DeviceManagementManagedDevices.Read.All` ⚠ |
|
||||||
|
|
||||||
|
> ⚠ Ohne `DeviceManagementManagedDevices.Read.All` zeigt der App Report 0 und der Geräte-Export liefert leere Dateien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Einschränkungen
|
||||||
|
|
||||||
|
- **Vendor-Apps** (PatchMyPC, Robopack, …) können nicht gelöscht/umbenannt werden — Lock-Icon + HTTP 409.
|
||||||
|
- **Native Zuweisungen** (All Users / All Devices) sind je App und Intent einzigartig — Duplikate werden ausgegraut.
|
||||||
|
- **Required-Zuweisungen** werden beim nächsten Geräte-Sync erzwungen — bei Produktiv-Apps zuerst Available testen.
|
||||||
|
- **App-Löschung** ist irreversibel und entfernt alle Zuweisungen.
|
||||||
|
- **Cache**: Apps und Gruppen werden im PowerShell-Prozess gehalten — Refresh-Icon lädt neu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vendor-Erkennung
|
||||||
|
|
||||||
|
Das Tool erkennt Vendor-Apps (PatchMyPC, Robopack, …) per konfigurierbaren Detection-Regeln in `settings.json → vendors[]`:
|
||||||
|
|
||||||
|
| Feld | Optionen |
|
||||||
|
|---|---|
|
||||||
|
| `field` | `commandLine`, `developer`, `publisher`, `displayName`, `notes`, `owner` |
|
||||||
|
| `match` | `contains`, `equals`, `startsWith`, `regex` |
|
||||||
|
| `pattern` | Suchstring oder Regex |
|
||||||
|
|
||||||
|
Erster Match gewinnt. Eigene Vendoren: neuen Eintrag im `vendors[]`-Array, Logo als `<id>.png` im Projekt-Root ablegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup-Datei aktualisieren
|
||||||
|
|
||||||
|
Native Win32-Apps (`.intunewin`) können mit einer neuen Setup-Datei aktualisiert werden ohne die App neu anzulegen.
|
||||||
|
|
||||||
|
**Wo:** App-Detail → Abschnitt *Setup-Datei* → *Setup-Datei aktualisieren…*
|
||||||
|
**Ablauf:** lokalen Pfad auf dem Server-Rechner eingeben → optionale neue Versionsnummer → Bestätigung → *Hochladen & aktivieren*
|
||||||
|
|
||||||
|
> **Achtung:** Ersetzt die ausgerollte Version für alle zugewiesenen Geräte. Nicht rückgängig machbar — zuerst an einer Test-App ausprobieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tastatur / Tipps
|
||||||
|
|
||||||
|
- **Ctrl+F5** — Hard-Refresh (Pflicht nach Tool-Updates)
|
||||||
|
- **Esc** — Modal schließen
|
||||||
|
- **Enter** im Umbenennen-Dialog — speichern
|
||||||
|
- **Klick auf App-Zeile** außerhalb Buttons — Detail öffnen/schließen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
| Symptom | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| „User canceled authentication" | WAM-Fenster ist hinter anderen Fenstern — in Taskleiste suchen |
|
||||||
|
| Apps laden sehr lange | Bei > 1000 Apps normal (10–30 s); danach gecached |
|
||||||
|
| Änderungen wirken nicht | **Ctrl+F5** (Browser-Cache) |
|
||||||
|
| App Report zeigt nur 0 | `DeviceManagementManagedDevices.Read.All` fehlt in der App-Registration |
|
||||||
|
| Geräte-Export ist leer | Gleiche Ursache wie App Report |
|
||||||
|
| Read-Only-Modus obwohl RW gewünscht | RW-Anmeldung fehlgeschlagen — Berechtigungen in Azure prüfen |
|
||||||
|
| Port-Konflikt beim Start | `.\Start.ps1 -Port 8088` |
|
||||||
|
| PowerShell-Errors beim Start | `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force` |
|
||||||
|
| Apply-Button bleibt grau | 0 Vorgänge in der Session oder nur native Targets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Stand: Juli 2026 · Hilfe-Datei: `www/help.md` · Änderungshistorie: `CHANGELOG.md`
|
||||||
+994
@@ -0,0 +1,994 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Intune Manager</title>
|
||||||
|
<script>
|
||||||
|
// Theme vor erstem Render anwenden — verhindert Flash
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('iam-theme');
|
||||||
|
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
} catch (e) { /* localStorage-Block */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
<link rel="stylesheet" href="/vendor/flatpickr.min.css">
|
||||||
|
<link rel="preconnect" href="https://rsms.me/">
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='22' fill='%23111111'/%3E%3Ctext x='50' y='70' font-size='62' text-anchor='middle' fill='white' font-family='Inter,Arial' font-weight='600'%3EI%3C/text%3E%3C/svg%3E">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div id="brandLogo" class="logo"><span class="logo-letter">I</span></div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="brand-name">Intune Manager</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="topbar-nav" id="topbarNav" aria-label="Hauptnavigation">
|
||||||
|
<button class="nav-tab active" data-view="apps" aria-selected="true">App Management</button>
|
||||||
|
<button class="nav-tab" data-view="groups" aria-selected="false">Group Management</button>
|
||||||
|
<button class="nav-tab" data-view="report" aria-selected="false">App Report</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="btnHelp" class="icon-btn icon-btn-header" title="Hilfe" aria-label="Hilfe öffnen">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="btnThemeToggle" class="theme-toggle" role="switch" aria-checked="false" title="Dark/Light Mode umschalten" aria-label="Dark/Light Mode umschalten">
|
||||||
|
<span class="theme-track">
|
||||||
|
<svg class="theme-ico theme-ico-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||||||
|
<svg class="theme-ico theme-ico-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
<span class="theme-knob"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div id="connStatus" class="conn-pill conn-off" title="Verbindungsstatus">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="conn-text">Offline</span>
|
||||||
|
</div>
|
||||||
|
<button id="btnDisconnect" class="icon-btn icon-btn-header hidden" title="Trennen" aria-label="Verbindung trennen">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ONBOARDING (MSAL.js Browser-Popup) -->
|
||||||
|
<div id="onboarding" class="onboarding hidden">
|
||||||
|
<div class="onboarding-card">
|
||||||
|
|
||||||
|
<!-- View 1: idle -->
|
||||||
|
<div data-onboarding-view="idle">
|
||||||
|
<div class="onboarding-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
|
<path d="M12 2 L4 6 v6 c0 5 3.5 9 8 10 c4.5-1 8-5 8-10 V6 z"/>
|
||||||
|
<path d="M9 12 l2 2 l4-4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="onboarding-title">Mit Microsoft anmelden</h1>
|
||||||
|
<p class="onboarding-text">
|
||||||
|
Es öffnet sich ein Microsoft-Anmeldefenster. Wähle den gewünschten Account aus.
|
||||||
|
</p>
|
||||||
|
<div id="onboardingSetupHint" class="banner-warning hidden">
|
||||||
|
Konfiguration unvollständig — bitte zuerst <a href="#" id="onboardingOpenSettings">Einstellungen</a> öffnen und Tenant ID, Client ID und Abteilungs-Präfix setzen.
|
||||||
|
</div>
|
||||||
|
<button id="btnConnect" class="btn btn-primary btn-lg">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M11.4 2.4H2.4v9h9v-9zm10.2 0h-9v9h9v-9zm-10.2 10.2H2.4v9h9v-9zm10.2 0h-9v9h9v-9z"/></svg>
|
||||||
|
Anmeldung starten
|
||||||
|
</button>
|
||||||
|
<div class="onboarding-meta">
|
||||||
|
Lese- und Zuweisungs-Rechte für Intune-Apps und Gruppenmitgliedschaften.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View 2: waiting -->
|
||||||
|
<div data-onboarding-view="awaiting" class="hidden">
|
||||||
|
<div class="dc-spinner-wrap">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<h2 class="onboarding-title">Anmeldung läuft</h2>
|
||||||
|
<p class="onboarding-text">
|
||||||
|
Bitte im Microsoft-Anmeldefenster fortfahren. Wir holen es automatisch in den Vordergrund —
|
||||||
|
falls es trotzdem im Hintergrund liegt, schau in der Taskleiste.
|
||||||
|
</p>
|
||||||
|
<button id="btnCancelConnect" class="btn btn-text">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View 3: error -->
|
||||||
|
<div data-onboarding-view="error" class="hidden">
|
||||||
|
<div class="onboarding-icon onboarding-icon-error">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="onboarding-title">Anmeldung fehlgeschlagen</h2>
|
||||||
|
<p class="onboarding-text" id="dcError">Unbekannter Fehler.</p>
|
||||||
|
<button id="btnRetryConnect" class="btn btn-primary btn-lg">Erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<main class="layout" id="layout" data-view="apps">
|
||||||
|
|
||||||
|
<!-- LEFT: TARGETS -->
|
||||||
|
<section class="panel panel-targets">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>Abteilungen & Benutzer</h2>
|
||||||
|
<span id="targetCount" class="count-badge">0</span>
|
||||||
|
</div>
|
||||||
|
<button id="btnReloadTargets" class="icon-btn" title="Neu laden" aria-label="Neu laden">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mode-segment" role="tablist">
|
||||||
|
<button class="seg active" data-mode="dept" role="tab">Abteilung</button>
|
||||||
|
<button class="seg" data-mode="rpa" role="tab">RPA</button>
|
||||||
|
<button class="seg" data-mode="user" role="tab">Benutzer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="targetSearch" placeholder="Filtern..." class="input input-search" autocomplete="off">
|
||||||
|
<span id="userSearchHint" class="search-hint hidden">min. 2 Zeichen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="select-toolbar">
|
||||||
|
<div class="select-actions">
|
||||||
|
<button id="btnTargetsSelectAll" class="link-btn">Alle</button>
|
||||||
|
<span class="dotsep">·</span>
|
||||||
|
<button id="btnTargetsClear" class="link-btn">Leeren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pinnedTargets" class="pinned-targets hidden" aria-label="Aktuelle Auswahl">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="targetList" class="list-area target-list">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CENTER: APPS -->
|
||||||
|
<section class="panel panel-apps">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>Apps</h2>
|
||||||
|
<span id="appCount" class="count-badge">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-head-actions">
|
||||||
|
<button id="btnReloadApps" class="icon-btn" title="Neu laden (Cache umgehen)" aria-label="Neu laden">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="apps-toolbar">
|
||||||
|
<div class="search-wrap flex-1">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="appSearch" placeholder="Apps durchsuchen..." class="input input-search" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<select id="appFilter" class="select">
|
||||||
|
<option value="all">Alle Apps</option>
|
||||||
|
<option value="none">Ohne Zuweisungen</option>
|
||||||
|
<option value="available">Nur Available</option>
|
||||||
|
<option value="required">Nur Required</option>
|
||||||
|
<option value="multipleAvail">Mehrere Available</option>
|
||||||
|
<option value="multipleReq">Mehrere Required</option>
|
||||||
|
<option value="allUsers">All Users</option>
|
||||||
|
<option value="allDevices">All Devices</option>
|
||||||
|
<option value="srcIntune">Nur Intune-Apps</option>
|
||||||
|
<!-- Vendor-Optionen werden dynamisch von rebuildVendorFilterOptions() ergaenzt -->
|
||||||
|
</select>
|
||||||
|
<select id="appCategoryFilter" class="select" title="Nach App-Kategorie filtern">
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
<!-- Kategorie-Optionen werden dynamisch von rebuildCategoryFilterOptions() befuellt -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-chips">
|
||||||
|
<label class="check-chip">
|
||||||
|
<input type="checkbox" id="chkOnlyMember">
|
||||||
|
<span>Bereits Gruppenmitglied</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-chip">
|
||||||
|
<input type="checkbox" id="chkHideNative">
|
||||||
|
<span>All Users/Devices ausblenden</span>
|
||||||
|
</label>
|
||||||
|
<div class="date-filter-chip" id="dateFilterChip">
|
||||||
|
<span class="date-filter-label" title="Nur Apps anzeigen die im angegebenen Zeitraum geändert wurden">Geändert</span>
|
||||||
|
<input type="date" id="dateModFrom" class="date-input" title="Von (inkl.)">
|
||||||
|
<span class="date-sep">–</span>
|
||||||
|
<input type="date" id="dateModTo" class="date-input" title="Bis (inkl.)">
|
||||||
|
<button type="button" id="dateModClear" class="date-clear" hidden title="Datum-Filter zurücksetzen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="legend-group">
|
||||||
|
<span class="legend-item" title="Alle ausgewählten Empfänger sind bereits Mitglied"><span class="row-status full">✓</span> bereits zugewiesen</span>
|
||||||
|
<span class="legend-item" title="Teilweise Mitglied"><span class="row-status partial">◐</span> teilweise</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="appList" class="list-area apps-list">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RIGHT: SESSION -->
|
||||||
|
<aside class="panel panel-side">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>Geplante Zuweisungen</h2>
|
||||||
|
<span id="sessionCount" class="count-badge accent">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-pane" id="sidePaneSession">
|
||||||
|
<div id="sessionList" class="session-list">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-summary">
|
||||||
|
<div class="summary-section">
|
||||||
|
<div class="summary-section-title">Ausgewählte Empfänger</div>
|
||||||
|
<div class="summary-row sub">
|
||||||
|
<span class="muted">Abteilungsgruppen</span>
|
||||||
|
<span id="sumGroupsSel">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row sub">
|
||||||
|
<span class="muted">Einzelbenutzer</span>
|
||||||
|
<span id="sumUsersSel">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-section">
|
||||||
|
<div class="summary-section-title">Zuweisungen erhalten</div>
|
||||||
|
<div class="summary-row sub">
|
||||||
|
<span class="muted">Abteilungsgruppen</span>
|
||||||
|
<span id="sumGroupsOps">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row sub">
|
||||||
|
<span class="muted">Einzelbenutzer</span>
|
||||||
|
<span id="sumUsersOps">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row total">
|
||||||
|
<span>Gesamt-Vorgänge</span>
|
||||||
|
<span id="sumTotal">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btnApply" class="btn btn-apply" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M5 12l5 5L20 7"/></svg>
|
||||||
|
<span id="btnApplyLabel">Zuweisungen ausführen</span>
|
||||||
|
</button>
|
||||||
|
<button id="btnSessionClear" class="link-btn link-btn-danger" hidden>Geplante Zuweisungen leeren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-pane hidden" id="sidePaneExisting">
|
||||||
|
<div class="side-pane-hint">Apps, in denen die ausgewählten Empfänger bereits Mitglied einer zugewiesenen Gruppe sind.</div>
|
||||||
|
<div id="existingList" class="existing-list">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- GROUP MANAGEMENT VIEW -->
|
||||||
|
<section class="group-mgmt-view hidden" id="groupMgmtView">
|
||||||
|
<div class="gex-body group-mgmt-body">
|
||||||
|
|
||||||
|
<!-- Tab-Leiste -->
|
||||||
|
<div class="gex-tab-bar group-mgmt-tabs">
|
||||||
|
<button class="gex-tab active" data-tab="export">Mitglieder & Export</button>
|
||||||
|
<button class="gex-tab gex-add-pane" data-tab="add">Benutzer hinzufügen</button>
|
||||||
|
<button class="gex-tab gex-add-pane" data-tab="import">CSV-Import</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gex-columns group-mgmt-columns">
|
||||||
|
|
||||||
|
<!-- Linke Spalte: Gruppensuche -->
|
||||||
|
<div class="gex-left">
|
||||||
|
<div class="gex-section-title">Gruppe suchen</div>
|
||||||
|
<div class="search-wrap" style="margin-bottom:10px;">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="gexSearch" class="input input-search" placeholder="Gruppenname eingeben…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="gexSearchResults" class="gex-results">
|
||||||
|
<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechte Spalte: Export-Tab -->
|
||||||
|
<div class="gex-right" id="gexPaneExport">
|
||||||
|
<div class="gex-section-title" id="gexMembersTitle">Mitglieder</div>
|
||||||
|
<div class="search-wrap" style="margin-bottom:10px;">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="gexMemberFilter" class="input input-search" placeholder="Mitglieder filtern…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="gexMembersArea" class="gex-members-area">
|
||||||
|
<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-mgmt-foot">
|
||||||
|
<span id="gexStats" class="muted"></span>
|
||||||
|
<button class="btn btn-primary" id="gexExportBtn" disabled>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Als CSV exportieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechte Spalte: CSV-Import -->
|
||||||
|
<div class="gex-right hidden" id="gexPaneImport">
|
||||||
|
<div class="gex-section-title">UPNs einfügen oder CSV hochladen</div>
|
||||||
|
<div class="csv-import-input-row">
|
||||||
|
<textarea id="csvImportText" class="csv-import-textarea" placeholder="Eine UPN/E-Mail pro Zeile, z.B. max.mustermann@firma.de erika.muster@firma.de Oder erste Spalte einer CSV (Header wird erkannt)."></textarea>
|
||||||
|
<label class="csv-upload-label" title="CSV-Datei auswählen">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
CSV laden
|
||||||
|
<input type="file" id="csvImportFile" accept=".csv,.txt" style="display:none">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="csv-import-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="csvResolveBtn">Benutzer auflösen</button>
|
||||||
|
<span id="csvResolveStats" class="muted" style="font-size:12px;"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Vorschau-Tabelle -->
|
||||||
|
<div id="csvPreviewWrap" class="csv-preview-wrap hidden">
|
||||||
|
<div class="gex-section-title" style="margin-top:10px;">
|
||||||
|
Vorschau
|
||||||
|
<span id="csvFoundBadge" class="count-badge" style="background:var(--success,#4ade80);color:#000;margin-left:6px;">0 gefunden</span>
|
||||||
|
<span id="csvNotFoundBadge" class="count-badge" style="background:var(--error,#f87171);color:#fff;margin-left:4px;">0 nicht gefunden</span>
|
||||||
|
</div>
|
||||||
|
<div class="csv-preview-table-wrap">
|
||||||
|
<table class="csv-preview-table">
|
||||||
|
<thead><tr><th></th><th>UPN / E-Mail</th><th>Name</th><th>Status</th></tr></thead>
|
||||||
|
<tbody id="csvPreviewBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Ergebnis-Banner (nach Import) -->
|
||||||
|
<div id="csvResultBanner" class="csv-result-banner hidden">
|
||||||
|
<div class="csv-result-stats">
|
||||||
|
<span id="csvResAdded" class="csv-res-chip csv-res-ok">0 hinzugefügt</span>
|
||||||
|
<span id="csvResExist" class="csv-res-chip csv-res-warn">0 bereits Mitglied</span>
|
||||||
|
<span id="csvResError" class="csv-res-chip csv-res-err">0 Fehler</span>
|
||||||
|
<span id="csvResSkipped" class="csv-res-chip csv-res-dim">0 nicht gefunden</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-mgmt-foot">
|
||||||
|
<span class="muted" id="csvImportNote" style="font-size:11px;">Gruppe links auswählen, dann Benutzer auflösen.</span>
|
||||||
|
<button class="btn btn-primary" id="csvImportBtn" disabled>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
||||||
|
Gefundene importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechte Spalte: Benutzer hinzufügen -->
|
||||||
|
<div class="gex-right hidden" id="gexPaneAdd">
|
||||||
|
<div class="gex-section-title">Benutzer suchen</div>
|
||||||
|
<div class="search-wrap" style="margin-bottom:10px;">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="gexUserSearch" class="input input-search" placeholder="Name, UPN oder E-Mail…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="gexUserResults" class="gex-results gex-add-results">
|
||||||
|
<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>
|
||||||
|
</div>
|
||||||
|
<div class="gex-section-title" style="margin-top:12px;">
|
||||||
|
Ausgewählt <span id="gexSelectedCount" class="count-badge">0</span>
|
||||||
|
</div>
|
||||||
|
<div id="gexSelectedUsers" class="gex-selected-area">
|
||||||
|
<div class="gex-hint">Noch keine Benutzer ausgewählt.</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-mgmt-foot">
|
||||||
|
<button class="btn btn-primary" id="gexAddBtn" disabled>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
||||||
|
Zu Gruppe hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- APP REPORT VIEW -->
|
||||||
|
<section class="app-report-view hidden" id="appReportView">
|
||||||
|
<div class="app-report-toolbar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="reportSearch" class="input input-search" placeholder="App suchen…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<span id="reportCount" class="count-badge">0</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btnReportRefresh" title="Daten neu laden">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
Neu laden
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btnReportExport" disabled>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Als CSV exportieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="app-report-table-wrap">
|
||||||
|
<table class="app-report-table" id="reportTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable" data-col="AppName">App-Name <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable" data-col="Publisher">Publisher <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable" data-col="AppType">Typ <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable" data-col="Version">Version <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable num" data-col="InstalledDeviceCount">Installiert <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable num" data-col="PendingInstallDeviceCount">Ausstehend <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable num" data-col="FailedDeviceCount">Fehlgeschlagen <span class="sort-ico"></span></th>
|
||||||
|
<th class="sortable num" data-col="NotApplicableDeviceCount">Nicht anwendbar <span class="sort-ico"></span></th>
|
||||||
|
<th class="report-th-export" title="Gerätestatus-Export"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="reportBody">
|
||||||
|
<tr><td colspan="9" class="report-hint">Klicke auf „Neu laden" um Daten zu laden.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TOAST CONTAINER -->
|
||||||
|
<div id="toasts" class="toasts"></div>
|
||||||
|
|
||||||
|
<!-- LOADING OVERLAY -->
|
||||||
|
<div id="loading" class="loading hidden">
|
||||||
|
<div class="loading-card">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div id="loadingText" class="loading-text">Bitte warten...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Create Required Group -->
|
||||||
|
<div id="modalCreateGroup" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Zuweisung hinzufügen</h3>
|
||||||
|
<button class="modal-close" data-close aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="cgAppName">App</label>
|
||||||
|
<input type="text" id="cgAppName" class="input" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl">Typ der Zuweisung</label>
|
||||||
|
<div class="cg-intent-grid">
|
||||||
|
|
||||||
|
<div class="cg-intent-col cg-intent-required">
|
||||||
|
<div class="cg-intent-head">
|
||||||
|
<span class="cg-intent-badge req">REQUIRED</span>
|
||||||
|
<span class="cg-intent-sub">App wird zwingend installiert</span>
|
||||||
|
</div>
|
||||||
|
<label class="cg-mode">
|
||||||
|
<input type="radio" name="cgMode" value="req-group" checked>
|
||||||
|
<span class="cg-mode-body">
|
||||||
|
<span class="cg-mode-title">Neue Gruppe</span>
|
||||||
|
<span class="cg-mode-desc">Neue Required-Gruppe nach Naming-Schema erstellen und zuweisen.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="cg-mode">
|
||||||
|
<input type="radio" name="cgMode" value="req-allusers">
|
||||||
|
<span class="cg-mode-body">
|
||||||
|
<span class="cg-mode-title">All Users</span>
|
||||||
|
<span class="cg-mode-desc">Native Required-Zuweisung an alle lizenzierten Benutzer.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="cg-mode">
|
||||||
|
<input type="radio" name="cgMode" value="req-alldevices">
|
||||||
|
<span class="cg-mode-body">
|
||||||
|
<span class="cg-mode-title">All Devices</span>
|
||||||
|
<span class="cg-mode-desc">Native Required-Zuweisung an alle Geräte des Tenants.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cg-intent-col cg-intent-available">
|
||||||
|
<div class="cg-intent-head">
|
||||||
|
<span class="cg-intent-badge avail">AVAILABLE</span>
|
||||||
|
<span class="cg-intent-sub">Selbstinstallation via Firmenportal</span>
|
||||||
|
</div>
|
||||||
|
<label class="cg-mode">
|
||||||
|
<input type="radio" name="cgMode" value="avail-group">
|
||||||
|
<span class="cg-mode-body">
|
||||||
|
<span class="cg-mode-title">Neue Gruppe</span>
|
||||||
|
<span class="cg-mode-desc">Neue Available-Gruppe nach Naming-Schema erstellen und zuweisen.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="cg-mode">
|
||||||
|
<input type="radio" name="cgMode" value="avail-allusers">
|
||||||
|
<span class="cg-mode-body">
|
||||||
|
<span class="cg-mode-title">All Users</span>
|
||||||
|
<span class="cg-mode-desc">App erscheint im Firmenportal aller Benutzer.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="cg-mode">
|
||||||
|
<input type="radio" name="cgMode" value="avail-alldevices">
|
||||||
|
<span class="cg-mode-body">
|
||||||
|
<span class="cg-mode-title">All Devices</span>
|
||||||
|
<span class="cg-mode-desc">App ist auf allen Geräten verfügbar.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="cgGroupNameGroup">
|
||||||
|
<label class="lbl" for="cgGroupName">Gruppen-Name (Vorschlag — kann angepasst werden)</label>
|
||||||
|
<input type="text" id="cgGroupName" class="input">
|
||||||
|
<div id="cgStatus" class="form-hint"></div>
|
||||||
|
</div>
|
||||||
|
<label class="check-chip" id="cgAssignToAppWrap">
|
||||||
|
<input type="checkbox" id="cgAssignToApp" checked>
|
||||||
|
<span>Gruppe direkt der App zuweisen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-secondary" data-close>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="cgCreate">Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Apply Result -->
|
||||||
|
<div id="modalResult" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="resHeadline">Ergebnis</h3>
|
||||||
|
<button class="modal-close" data-close aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="resBanner" class="res-banner"></div>
|
||||||
|
|
||||||
|
<div class="result-stats">
|
||||||
|
<div class="rstat ok"><div class="num" id="resOk">0</div><div class="lbl">Erfolgreich</div></div>
|
||||||
|
<div class="rstat warn"><div class="num" id="resSkip">0</div><div class="lbl">Übersprungen</div></div>
|
||||||
|
<div class="rstat err"><div class="num" id="resErr">0</div><div class="lbl">Fehler</div></div>
|
||||||
|
<div class="rstat tot"><div class="num" id="resTotal">0</div><div class="lbl">Gesamt</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resDetails" class="res-details"></div>
|
||||||
|
|
||||||
|
<div id="resReportLink" class="report-link"></div>
|
||||||
|
|
||||||
|
<div id="resDebug" class="res-debug"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-secondary" data-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Mitglieder einer Gruppe einsehen / einzelne User uebernehmen -->
|
||||||
|
<div id="modalMembers" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="mmTitle">Mitglieder</h3>
|
||||||
|
<button class="modal-close" data-close aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="search-wrap" style="margin: 0 0 12px 0;">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="mmSearch" class="input input-search" placeholder="Mitglieder filtern...">
|
||||||
|
</div>
|
||||||
|
<div class="mm-toolbar">
|
||||||
|
<button class="link-btn" id="mmSelectAll">Alle</button>
|
||||||
|
<span class="dotsep">·</span>
|
||||||
|
<button class="link-btn" id="mmClear">Leeren</button>
|
||||||
|
</div>
|
||||||
|
<div id="mmList" class="member-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span id="mmInfo" class="muted" style="margin-right: auto;">0 ausgewählt</span>
|
||||||
|
<button class="btn btn-secondary" data-close>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="mmApply">Als Benutzer-Auswahl übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Confirm Apply -->
|
||||||
|
<div id="modalConfirm" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Zuweisungen ausführen?</h3>
|
||||||
|
<button class="modal-close" data-close aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="confirmText" class="confirm-text"></div>
|
||||||
|
<div class="confirm-warn">Hinzufügen ist sofort wirksam — Mitgliedschaften können später manuell entfernt werden.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-secondary" data-close>Abbrechen</button>
|
||||||
|
<button class="btn btn-apply" id="btnConfirmApply">Ja, ausführen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Hilfe -->
|
||||||
|
<div id="modalHelp" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box modal-xl">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Hilfe & 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 & aktivieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Einstellungen -->
|
||||||
|
<div id="modalSettings" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Einstellungen</h3>
|
||||||
|
<button class="modal-close" data-close aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="settingsForm" class="settings-form">
|
||||||
|
|
||||||
|
<div id="setupBanner" class="banner-warning hidden">
|
||||||
|
<strong>Erste Konfiguration noetig.</strong>
|
||||||
|
Bitte mindestens Tenant ID, Client ID und Abteilungs-Praefix setzen. Danach <em>Verbindung + Lookups testen</em> ganz unten, dann speichern.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="settings-sec">
|
||||||
|
<div class="settings-sec-head">
|
||||||
|
<h4>Microsoft Graph Verbindung</h4>
|
||||||
|
<div class="settings-sec-sub">Tenant- und App-Registrierung. Änderungen erzwingen einen Disconnect.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="setTenantId">Tenant ID (GUID)</label>
|
||||||
|
<input type="text" id="setTenantId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
|
||||||
|
<div class="form-hint">Findest du im Entra Admin Center → Übersicht → Tenant-Informationen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="setClientId">Client ID – Read/Write (GUID)</label>
|
||||||
|
<input type="text" id="setClientId" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
|
||||||
|
<div class="form-hint">App-Registrierung mit Schreibrechten. Erforderlich: <em>Allow public client flows</em> = Ja, Redirect URI <code>http://localhost</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="setClientIdRo">Client ID – Read Only (GUID, optional)</label>
|
||||||
|
<input type="text" id="setClientIdRo" class="input mono" placeholder="00000000-0000-0000-0000-000000000000" spellcheck="false" autocomplete="off">
|
||||||
|
<div class="form-hint">Separate App-Registrierung ohne Schreibrechte. Nutzer dieser App sehen alle Daten, können aber nichts ändern. Leer lassen wenn nicht benötigt.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="setScopes">Scopes Read/Write (eine pro Zeile)</label>
|
||||||
|
<textarea id="setScopes" class="input mono" rows="4" spellcheck="false" placeholder="Group.ReadWrite.All GroupMember.ReadWrite.All User.Read.All DeviceManagementApps.ReadWrite.All"></textarea>
|
||||||
|
<div class="form-hint">Die Standard-Liste passt für den vollen Funktionsumfang.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="setScopesRo">Scopes Read Only (eine pro Zeile)</label>
|
||||||
|
<textarea id="setScopesRo" class="input mono" rows="4" spellcheck="false" placeholder="Group.Read.All GroupMember.Read.All User.Read.All DeviceManagementApps.Read.All"></textarea>
|
||||||
|
<div class="form-hint">Scopes für die Read-Only-App-Registrierung.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-sec">
|
||||||
|
<div class="settings-sec-head">
|
||||||
|
<h4>Abteilungs-Gruppen</h4>
|
||||||
|
<div class="settings-sec-sub">Ein oder mehrere Präfixe für den Abteilungs-Modus (linke Spalte). Alle Gruppen, deren <code>displayName</code> mit einem dieser Präfixe beginnt, werden geladen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="lbl" for="setDeptPrefixes">Präfixe (eine pro Zeile)</label>
|
||||||
|
<textarea id="setDeptPrefixes" class="input mono" rows="3" spellcheck="false" placeholder="abt-hm 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 intune-userrole-windowsclientuser-rpa-fbt 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.<ext></code> gespeichert. Max. 2 MB.</div>
|
||||||
|
</div>
|
||||||
|
<div class="logo-row">
|
||||||
|
<div class="logo-preview" id="logoPreview">
|
||||||
|
<span class="logo-letter">I</span>
|
||||||
|
</div>
|
||||||
|
<div class="logo-actions">
|
||||||
|
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif" class="hidden">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="btnLogoPick">Bild auswählen…</button>
|
||||||
|
<button type="button" class="btn btn-text link-btn-danger" id="btnLogoRemove" hidden>Entfernen</button>
|
||||||
|
<div class="form-hint" id="logoHint">PNG, JPG, SVG, WEBP, GIF</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-sec">
|
||||||
|
<div class="settings-sec-head settings-sec-head-row">
|
||||||
|
<div>
|
||||||
|
<h4>Farben</h4>
|
||||||
|
<div class="settings-sec-sub">Akzentfarben für Highlights. Soft/Hover/Background-Varianten werden automatisch abgeleitet.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="btnRestoreColors" title="Alle Farben auf die Firmen-Standardwerte zurücksetzen">↺ Standardfarben wiederherstellen</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-grid">
|
||||||
|
<div class="color-row" data-color-key="brand">
|
||||||
|
<span class="color-swatch" data-swatch="brand"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Brand (Firmenfarbe)</div>
|
||||||
|
<div class="color-desc">Logo-Hintergrund, Stepper-Indikator</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColBrand" class="color-picker">
|
||||||
|
<input type="text" id="setColBrandHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="brand" title="Auf Standard zurücksetzen">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="accent">
|
||||||
|
<span class="color-swatch" data-swatch="accent"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Available</div>
|
||||||
|
<div class="color-desc">Available-Buttons, Links, Akzent-Hover</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColAccent" class="color-picker">
|
||||||
|
<input type="text" id="setColAccentHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="accent" title="Auf Standard zurücksetzen">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="required">
|
||||||
|
<span class="color-swatch" data-swatch="required"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Required</div>
|
||||||
|
<div class="color-desc">Required-Buttons und -Badges</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColRequired" class="color-picker">
|
||||||
|
<input type="text" id="setColRequiredHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="required">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="success">
|
||||||
|
<span class="color-swatch" data-swatch="success"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Erfolg / Bereits zugewiesen</div>
|
||||||
|
<div class="color-desc">Member-Indikatoren, Erfolgs-Toasts</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColSuccess" class="color-picker">
|
||||||
|
<input type="text" id="setColSuccessHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="success">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="warning">
|
||||||
|
<span class="color-swatch" data-swatch="warning"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Warnung / Teilweise</div>
|
||||||
|
<div class="color-desc">Partial-Member, Skip-Statuses</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColWarning" class="color-picker">
|
||||||
|
<input type="text" id="setColWarningHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="warning">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="error">
|
||||||
|
<span class="color-swatch" data-swatch="error"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Fehler</div>
|
||||||
|
<div class="color-desc">Fehler-Toasts, Delete-Hover, Validation</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColError" class="color-picker">
|
||||||
|
<input type="text" id="setColErrorHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="error">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="rowSelected">
|
||||||
|
<span class="color-swatch" data-swatch="rowSelected"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Markierte App-Zeile</div>
|
||||||
|
<div class="color-desc">Hintergrund der aufgeklappten App-Zeile + linker Akzentstreifen. Schrift/Icons passen sich automatisch dem Kontrast an.</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColRowSelected" class="color-picker">
|
||||||
|
<input type="text" id="setColRowSelectedHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="rowSelected">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-row" data-color-key="detailPanel">
|
||||||
|
<span class="color-swatch" data-swatch="detailPanel"></span>
|
||||||
|
<div class="color-meta">
|
||||||
|
<div class="color-name">Details-Bereich</div>
|
||||||
|
<div class="color-desc">Hintergrund des aufgeklappten Detail-Blocks unter der App. Text-Farbe wird automatisch nach Lesbarkeit gewählt.</div>
|
||||||
|
</div>
|
||||||
|
<input type="color" id="setColDetailPanel" class="color-picker">
|
||||||
|
<input type="text" id="setColDetailPanelHex" class="input mono color-hex" maxlength="7" spellcheck="false">
|
||||||
|
<button type="button" class="link-btn" data-color-reset="detailPanel">↺</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-sec">
|
||||||
|
<div class="settings-sec-head">
|
||||||
|
<h4>Konfiguration pruefen</h4>
|
||||||
|
<div class="settings-sec-sub">Prueft die aktuell eingetragenen Werte gegen Microsoft Graph: Abteilungs-Gruppen-Lookup, RPA-Gruppen und Benutzer-Suche. Voraussetzung: Verbindung steht.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<button type="button" class="btn btn-secondary" id="btnSettingsTest">Verbindung + Lookups testen</button>
|
||||||
|
<div id="settingsTestResult" class="settings-test-result"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-sec settings-sec-meta">
|
||||||
|
<div class="settings-sec-head">
|
||||||
|
<h4>Datei</h4>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint">Gespeichert in: <code id="setFilePath" class="set-path">…</code></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-text link-btn-danger" id="btnSettingsReset" type="button">Auf Standardwerte zurücksetzen</button>
|
||||||
|
<span class="modal-foot-spacer"></span>
|
||||||
|
<button class="btn btn-secondary" data-close>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="btnSettingsSave">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Gruppen-Export (leeres Stub — Inhalt jetzt in #groupMgmtView) -->
|
||||||
|
<div id="modalGroupExport" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-box modal-xl">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Gruppen-Verwaltung</h3>
|
||||||
|
<button class="modal-close" data-close aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown-Renderer (bundled, MIT, marked v15) — wird im App-Detail-Panel
|
||||||
|
fuer die Beschreibung benutzt. Vor app.js laden damit window.marked
|
||||||
|
beim ersten render() bereitsteht. -->
|
||||||
|
<script src="/vendor/marked.min.js"></script>
|
||||||
|
<!-- Date-Picker (bundled, MIT, flatpickr v4) — ersetzt den nativen
|
||||||
|
date-input Popup, vermeidet Browser-Quirks (schwarzes Initial-Render). -->
|
||||||
|
<script src="/vendor/flatpickr.min.js"></script>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gruppen-Mitglieder · Intune Manager</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('iam-theme');
|
||||||
|
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
<link rel="preconnect" href="https://rsms.me/">
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='22' fill='%23111111'/%3E%3Ctext x='50' y='70' font-size='62' text-anchor='middle' fill='white' font-family='Inter,Arial' font-weight='600'%3EI%3C/text%3E%3C/svg%3E">
|
||||||
|
</head>
|
||||||
|
<body class="members-page">
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">I</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="brand-name">Gruppen-Mitglieder</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a id="entraLink" class="btn btn-secondary btn-sm" href="#" target="_blank" rel="noopener" title="In Entra oeffnen">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
In Entra
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="members-main">
|
||||||
|
<section class="members-card">
|
||||||
|
<header class="members-head">
|
||||||
|
<h1 id="grpName" class="members-title">Mitglieder werden geladen...</h1>
|
||||||
|
<div class="members-meta">
|
||||||
|
<code id="grpId" class="meta-mono"></code>
|
||||||
|
<span id="grpCount" class="count-badge accent">0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="members-toolbar">
|
||||||
|
<div class="search-wrap flex-1">
|
||||||
|
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="search" id="mFilter" placeholder="Mitglieder filtern (Name, UPN, E-Mail)..." class="input input-search" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button id="btnReload" class="icon-btn" title="Neu laden" aria-label="Neu laden">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mList" class="members-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toasts" class="toasts"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const groupId = params.get('id') || '';
|
||||||
|
const groupName = params.get('name') || '';
|
||||||
|
|
||||||
|
const ENTRA_GROUP_URL = id => `https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Overview/groupId/${encodeURIComponent(id)}/menuId/`;
|
||||||
|
const ENTRA_USER_URL = id => `https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${encodeURIComponent(id)}`;
|
||||||
|
const ENTRA_ICON_SVG = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(name, count) {
|
||||||
|
document.title = `${name || groupId} (${count}) · Mitglieder`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = { members: [], filter: '' };
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const q = state.filter.trim().toLowerCase();
|
||||||
|
const items = q
|
||||||
|
? state.members.filter(m =>
|
||||||
|
(m.DisplayName || '').toLowerCase().includes(q) ||
|
||||||
|
(m.UserPrincipalName || '').toLowerCase().includes(q))
|
||||||
|
: state.members;
|
||||||
|
|
||||||
|
const list = document.getElementById('mList');
|
||||||
|
document.getElementById('grpCount').textContent = state.members.length;
|
||||||
|
setTitle(document.getElementById('grpName').textContent, state.members.length);
|
||||||
|
|
||||||
|
if (state.members.length === 0) {
|
||||||
|
list.innerHTML = `<div class="empty-state"><div class="empty-title">Keine Mitglieder</div><div class="empty-text">Diese Gruppe hat keine direkten Benutzer.</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = `<div class="empty-state small"><div class="empty-title">Keine Treffer</div><div class="empty-text">Filter zuruecksetzen.</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = items.map(m => {
|
||||||
|
const upn = m.UserPrincipalName ? `<div class="m-upn">${esc(m.UserPrincipalName)}</div>` : '';
|
||||||
|
const icon = `<a class="entra-link entra-link-static" href="${ENTRA_USER_URL(m.Id)}" target="_blank" rel="noopener" title="In Entra oeffnen">${ENTRA_ICON_SVG}</a>`;
|
||||||
|
return `<div class="m-row">
|
||||||
|
<div class="m-info">
|
||||||
|
<div class="m-name">${esc(m.DisplayName || '?')}${icon}</div>
|
||||||
|
${upn}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(force = false) {
|
||||||
|
if (!groupId) {
|
||||||
|
document.getElementById('mList').innerHTML = `<div class="empty-state"><div class="empty-title">Keine Gruppe</div><div class="empty-text">In der URL fehlt der Parameter <code>?id=...</code>.</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const titleEl = document.getElementById('grpName');
|
||||||
|
titleEl.textContent = groupName || 'Mitglieder werden geladen...';
|
||||||
|
document.getElementById('grpId').textContent = groupId;
|
||||||
|
document.getElementById('entraLink').href = ENTRA_GROUP_URL(groupId);
|
||||||
|
|
||||||
|
document.getElementById('mList').innerHTML = `<div class="m-loading"><div class="spinner"></div><span>Lade Mitglieder...</span></div>`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members${force ? '?refresh=1' : ''}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
state.members = (data.items || [])
|
||||||
|
.sort((a, b) => (a.DisplayName || '').localeCompare(b.DisplayName || '', 'de', { sensitivity: 'base' }));
|
||||||
|
titleEl.textContent = groupName || `Gruppe ${groupId.slice(0, 8)}`;
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('mList').innerHTML = `<div class="empty-state"><div class="empty-title">Fehler</div><div class="empty-text">${esc(e.message)}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('mFilter').addEventListener('input', e => {
|
||||||
|
state.filter = e.target.value;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById('btnReload').addEventListener('click', () => load(true));
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+5159
File diff suppressed because it is too large
Load Diff
Vendored
+13
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+69
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user