diff --git a/Intune/Intune-App-Assignment-Application-v5_2_0_MW.ps1 b/Intune/Intune-App-Assignment-Application-v5_2_0_MW.ps1 index ea85930..d2bd37e 100644 --- a/Intune/Intune-App-Assignment-Application-v5_2_0_MW.ps1 +++ b/Intune/Intune-App-Assignment-Application-v5_2_0_MW.ps1 @@ -37,7 +37,8 @@ function Get-GraphGroup { $select = if ($Property) { "&`$select=" + ($Property -join ",").ToLower() } else { "" } if ($GroupId) { - $uri = "https://graph.microsoft.com/v1.0/groups/$GroupId`?`$select=" + ($Property -join ",").ToLower() + $selectPart = if ($Property) { "?`$select=" + ($Property -join ",").ToLower() } else { "" } + $uri = "https://graph.microsoft.com/v1.0/groups/$GroupId$selectPart" return Invoke-MgGraphRequest -Uri $uri -Method GET } else { @@ -1614,21 +1615,29 @@ function Update-MembershipIndicators { $memberships = @() if ($target.Type -eq "User") { # Benutzer: Direkte Gruppenmitgliedschaften laden - $groups = Get-MgUserMemberOf -UserId $target.Id -All -Property Id 2>$null - foreach ($g in $groups) { - if ($g.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') { - $memberships += $g.Id + $uri = "https://graph.microsoft.com/v1.0/users/$($target.Id)/memberOf?`$select=id" + do { + $response = Invoke-MgGraphRequest -Uri $uri -Method GET + foreach ($g in $response.value) { + if ($g.'@odata.type' -eq '#microsoft.graph.group') { + $memberships += $g.id + } } - } + $uri = $response.'@odata.nextLink' + } while ($uri) } else { # Gruppe: Transitive Mitgliedschaften laden (Gruppe ist Mitglied von...) - $groups = Get-MgGroupMemberOf -GroupId $target.Id -All -Property Id 2>$null - foreach ($g in $groups) { - if ($g.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') { - $memberships += $g.Id + $uri = "https://graph.microsoft.com/v1.0/groups/$($target.Id)/memberOf?`$select=id" + do { + $response = Invoke-MgGraphRequest -Uri $uri -Method GET + foreach ($g in $response.value) { + if ($g.'@odata.type' -eq '#microsoft.graph.group') { + $memberships += $g.id + } } - } + $uri = $response.'@odata.nextLink' + } while ($uri) } $script:membershipCache[$target.Id] = $memberships } diff --git a/Intune/Intune-App-Manager-Web/CHANGELOG.md b/Intune/Intune-App-Manager-Web/CHANGELOG.md new file mode 100644 index 0000000..9d07cf4 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/CHANGELOG.md @@ -0,0 +1,234 @@ +# Changelog + +Format orientiert an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). +Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solange das Tool noch in der `0.x.y`-Phase ist, sind Breaking Changes erlaubt. + +--- + +## [0.1.23] - 2026-05-21 + +Setup-Datei nativer Apps aktualisierbar + Microsoft-Graph-Audit-Fixes. + +### Neu + +- **Setup-Datei aktualisieren** (Phase 1: `.intunewin` / win32LobApp): im App-Detail neuer Abschnitt *Setup-Datei* mit Button, der eine neue Setup-Datei von einem lokalen Server-Pfad hochlaedt und als neue contentVersion aktiviert — inkl. optionaler neuer Versionsnummer (`displayVersion`). Voller Graph-Upload-Flow: contentVersion → file → Azure-Blob-Chunk-Upload → commit → committedContentVersion. Vendor-verwaltete Apps (PMPC/Robopack) sind ausgeschlossen. MSI/MSIX folgen in spaeteren Phasen +- **Kategorie-Filter** in der App-Liste: neuer Dropdown, der sich dynamisch aus den vorkommenden App-Kategorien fuellt (inkl. *Ohne Kategorie*) +- **Kategorien-Anzeige** im App-Detail (immer sichtbar; "Default" wenn keine zugewiesen) +- **Mehrere Abteilungs-Praefixe** konfigurierbar (z.B. `abt-hm` *und* `abt-extern` parallel): neues Schema `departments.prefixes[]`, Settings-UI mit Textarea (eine pro Zeile). Der Graph-Filter wird OR-verkettet, ein einziger Listen-Request. Alter Single-String `prefix` wird weiter gelesen (Backward-Compat); Test-Endpoint zeigt Trefferzahl pro Praefix + +### Geändert + +- **App-Liste laedt `$expand=categories`** mit — `categories` ist eine Graph-Navigation-Property und kommt sonst leer (`null`) zurueck +- **Default-Scope** `DeviceManagementApps.Read.All` → `DeviceManagementApps.ReadWrite.All`: assign/rename/delete/content-update auf mobileApps brauchen ReadWrite (verifiziert via msgraph-Skill gegen den offiziellen Permission-Index) + +### Behoben + +- **429/503-Throttling**: neuer Retry-Wrapper `Invoke-MgGraphRequestRetry` mit `Retry-After`-Honorierung + Backoff (2/5/10s) in `Get-GraphPaged` (alle Lese-Pfade) + `Add-GraphMember` (Bulk-Member-Add). 403/404 werden unveraendert durchgereicht +- **Kategorien** wurden nie angezeigt — Ursache war die fehlende `$expand`-Klausel (s.o.), nicht das Frontend + +### Technisch + +- Neue Graph-Funktionen: `Read-IntuneWinPackage`, `New-GraphAppContentVersion`, `New-GraphAppContentFile`, `Wait-GraphContentFileState`, `Send-GraphContentToAzureBlob`, `Invoke-GraphContentRenewUpload`, `Invoke-GraphContentCommit`, `Update-GraphAppContent`, `Invoke-MgGraphRequestRetry` +- Neuer Endpoint: `POST /api/apps/{id}/content` (`Update-AppContentEndpoint`), Body `{ filePath, displayVersion? }` +- Intune-Content-Verschluesselung dokumentiert (AES-256-CBC + HMAC-SHA256, Layout `[HMAC|IV|Ciphertext]`, ProfileVersion1) — fuer `.intunewin` aus Detection.xml uebernommen +- `api()`-Helper unterstuetzt jetzt `timeoutMs` (AbortController) fuer lange Upload-Requests + +--- + +## [0.1.22] - 2026-05-19 + +Robopack als zweiter Vendor analog zu PatchMyPC. Detection-Regeln wurden vom Hardcode in eine konfigurierbare Vendor-Registry verlagert. + +### Neu + +- **Robopack-Integration**: eigenes Source-Logo (`robopack.png`), Portal-Link auf `https://app.robopack.com/`, Lock-Icons fuer Delete/Rename, Filter-Eintrag "Nur Robopack-Apps". Default-Detection: `developer equals "Robopack"` +- **Vendor-Registry in `settings.json`**: neue Sektion `vendors[]` mit `id`, `displayName`, `portalUrl`, `logoFile`, `detection.field` (commandLine | developer | publisher | displayName | notes | owner), `detection.match` (contains | equals | regex | startsWith), `detection.pattern`, `block.delete`, `block.rename`. Mehrere Vendoren moeglich, erster Match gewinnt +- **Dynamisches Filter-Dropdown**: pro konfiguriertem Vendor wird eine Option `Nur -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 = "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 (`-team-controlling`, `intune-win-app--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 diff --git a/Intune/Intune-App-Manager-Web/README.md b/Intune/Intune-App-Manager-Web/README.md new file mode 100644 index 0000000..199d960 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/README.md @@ -0,0 +1,115 @@ +# Intune App-Manager – Web Edition + +Modernes Web-Frontend fuer den Intune App-Zuweisungs-Manager. +PowerShell startet einen lokalen HTTP-Server, der das Frontend ausliefert +und die Microsoft Graph-Aufrufe ausfuehrt. + +## Voraussetzungen + +- Windows mit PowerShell 5.1 oder PowerShell 7+ +- Modul `Microsoft.Graph.Authentication` (wird beim ersten Connect benoetigt): + ```powershell + Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force + ``` +- Browser (Edge, Chrome, Firefox) + +## Start + +```powershell +cd "c:\Temp\claude.code\Intune App Management\Intune-App-Manager-Web" +.\Start.ps1 +``` + +Optionen: + +| Parameter | Standard | Beschreibung | +|---------------|-----------------------------------------|---------------------------------------------| +| `-Port` | `8077` | Port des lokalen HTTP-Servers | +| `-TenantId` | (HanseMerkur Default) | Microsoft Entra Tenant-ID | +| `-ClientId` | (HanseMerkur Default) | App-Registrierung Client-ID | +| `-NoBrowser` | aus | Browser nicht automatisch oeffnen | + +Beispiele: + +```powershell +.\Start.ps1 -Port 8088 +.\Start.ps1 -TenantId "..." -ClientId "..." +.\Start.ps1 -NoBrowser +``` + +Beenden: `Ctrl+C` im Terminal. + +## Workflow + +1. **Verbinden** – oben rechts klicken; Microsoft Login-Fenster oeffnet sich. +2. **Modus waehlen**: Abteilungen / RPA-Gruppen / Einzel-User. +3. **Ziele laden** – ueber "Laden" (User: in Suchfeld tippen). +4. **Ziele markieren** mit Checkboxen. +5. **Apps laden** – ueber "Laden" rechts neben "Apps". +6. **Pillen klicken** – jede gruene Pille bei einer App ist eine zugewiesene + Gruppe. Klick = "Ausgewaehlte Ziele dieser Gruppe hinzufuegen". Erneuter + Klick = entfernen. +7. **Required-Gruppe erstellen** – falls noetig, ueber "+ Required-Gruppe" + bei der App. +8. **Session pruefen** – rechts werden alle geplanten Operationen aufgelistet. +9. **Ausfuehren** – gruener Button rechts unten. HTML-Report erscheint + automatisch nach Abschluss. + +## Pillen-Farben (App-Zeile) + +| Farbe | Bedeutung | +|-------------|-------------------------------------------------------------------| +| Grau | Gruppe ist der App zugewiesen, Ziele sind keine Mitglieder | +| Gruen | Alle ausgewaehlten Ziele sind bereits Mitglied | +| Gelb | Einige Ziele sind Mitglied (Partial) | +| Cyan | Native Target (All Users / All Devices) – nicht aenderbar | +| Blau | Aktuell zur Session hinzugefuegt | +| Gestrichelt | "+ Required-Gruppe" Button | + +## Architektur + +``` +Intune-App-Manager-Web/ +├── Start.ps1 Entry Point +├── README.md diese Datei +├── src/ +│ ├── Models.ps1 Hilfen / Datenklassen +│ ├── Graph.ps1 Microsoft Graph API Helpers +│ ├── Server.ps1 HTTP-Server (HttpListener) +│ ├── Router.ps1 Request-Routing +│ └── Api.ps1 REST-API-Endpoints +└── www/ + ├── index.html SPA-Shell + ├── styles.css Modernes Dark Theme + └── app.js Frontend-Logik (Vanilla JS) +``` + +Reports werden in `%TEMP%\IntuneAppManager-Reports\` gespeichert und sind +ueber `http://localhost:/reports/.html` erreichbar. + +## REST-API (intern) + +| Methode | Pfad | Zweck | +|---------|----------------------------------------------|------------------------------------| +| GET | `/api/status` | Verbindungsstatus | +| POST | `/api/connect` | Microsoft Graph verbinden | +| POST | `/api/disconnect` | Trennen | +| GET | `/api/groups` | Abteilungs-Gruppen (`abt-hm-*`) | +| GET | `/api/groups/rpa` | 3 vordefinierte RPA-Gruppen | +| POST | `/api/groups` | Neue Required-Gruppe erstellen | +| POST | `/api/groups/check` | Gruppen-Name pruefen | +| GET | `/api/groups/{id}/members` | Mitglieder einer Gruppe | +| GET | `/api/users?q=...` | User suchen | +| GET | `/api/apps` | Apps laden (cached) | +| GET | `/api/apps?refresh=true` | Apps neu laden | +| GET | `/api/membership?targets=...&groupId=...` | Mitgliedschafts-Status pruefen | +| POST | `/api/assignments/apply` | Geplante Zuweisungen ausfuehren | + +## Hinweise + +- **Single-User-App** – der lokale HTTP-Server nimmt nur eine Verbindung + zur Zeit zuverlaessig an. Den Browser-Tab bitte einzeln halten. +- **Auth lebt im PowerShell-Prozess** – Disconnect oder Schliessen des + Terminals beendet die Session. +- **Cache** – Apps werden im Server-Prozess gecached (Refresh-Knopf zum + Neuladen). Membership-Cache wird bei Aenderung der Ziel-Auswahl geleert. diff --git a/Intune/Intune-App-Manager-Web/Run.cmd b/Intune/Intune-App-Manager-Web/Run.cmd new file mode 100644 index 0000000..a65eeca --- /dev/null +++ b/Intune/Intune-App-Manager-Web/Run.cmd @@ -0,0 +1,19 @@ +@echo off +REM Launcher fuer Start.ps1 — funktioniert auf allen Clients, unabhaengig +REM davon ob .ps1-Dateien standardmaessig mit PowerShell oder Notepad +REM verknuepft sind. Verknuepfungen koennen direkt auf diese .cmd zeigen. +REM +REM -NoProfile : keine User-Profile-Skripte laden (schneller, sauberer) +REM -ExecutionPolicy : Bypass nur fuer diesen Prozess, keine Systemaenderung +REM -WindowStyle Minimized : PowerShell-Fenster startet minimiert (Taskleiste). +REM Browser oeffnet sich trotzdem; bei Bedarf kann das +REM PS-Fenster ueber die Taskleiste sichtbar gemacht +REM werden (z.B. fuer Live-Logs). +REM -File ... %* : Start.ps1 ausfuehren, alle Argumente durchreichen +REM +REM Beispielaufrufe: +REM Run.cmd +REM Run.cmd -Port 8088 +REM Run.cmd -NoBrowser + +start "Intune App-Manager" /min powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%~dp0Start.ps1" %* diff --git a/Intune/Intune-App-Manager-Web/Start.ps1 b/Intune/Intune-App-Manager-Web/Start.ps1 new file mode 100644 index 0000000..d0b200b --- /dev/null +++ b/Intune/Intune-App-Manager-Web/Start.ps1 @@ -0,0 +1,221 @@ +# Intune App-Zuweisungs-Manager - Web Edition +# Startet einen lokalen HTTP-Server und oeffnet das moderne Web-Frontend. +# +# Aufruf: +# .\Start.ps1 +# .\Start.ps1 -Port 8088 -NoBrowser +# .\Start.ps1 -TenantId "..." -ClientId "..." + +[CmdletBinding()] +param( + [int]$Port = 8077, + # Optional: ueberschreibt die persistierten Settings nur fuer diesen Lauf. + # Wenn nicht gesetzt -> Werte aus %APPDATA%\IntuneAppManager-Web\settings.json + # (bzw. den Defaults beim Erststart) werden verwendet. + [string]$TenantId = "", + [string]$ClientId = "", + [switch]$NoBrowser, + [switch]$AutoInstall, # ohne Rueckfrage installieren + [switch]$SkipModuleCheck # Pre-Flight ueberspringen (z.B. CI) +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $MyInvocation.MyCommand.Path + +# ============================================================ +# Pre-Flight: PowerShell-Module pruefen / installieren +# (nur User-Scope, nichts systemweites) +# ============================================================ + +function Test-RequiredModules { + [CmdletBinding()] + param( + [hashtable[]]$Required, + [switch]$AutoInstall + ) + + Write-Host "Pruefe PowerShell-Module..." -ForegroundColor Cyan + $missing = @() + $outdated = @() + + foreach ($req in $Required) { + $name = $req.Name + $minV = [version]$req.MinVersion + $available = @(Get-Module -Name $name -ListAvailable -ErrorAction SilentlyContinue) + if ($available.Count -eq 0) { + Write-Host " [FEHLT] $name (>= $minV)" -ForegroundColor Yellow + $missing += $req + } else { + $maxV = ($available | Sort-Object Version -Descending | Select-Object -First 1).Version + if ($maxV -lt $minV) { + Write-Host " [ALT] $name v$maxV (<$minV)" -ForegroundColor Yellow + $outdated += @{ Name = $name; Have = $maxV; Need = $minV } + } else { + Write-Host " [OK] $name v$maxV" -ForegroundColor Green + } + } + } + + if ($missing.Count -eq 0 -and $outdated.Count -eq 0) { + return $true + } + + # NuGet-Provider sicherstellen, sonst schlaegt Install-Module schweigend fehl + $nuget = Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue + if (-not $nuget -or $nuget.Version -lt [version]"2.8.5.201") { + Write-Host "" + Write-Host "NuGet-PackageProvider fehlt — wird im User-Scope nachgezogen..." -ForegroundColor Yellow + try { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -ErrorAction Stop | Out-Null + } catch { + throw "NuGet-Provider konnte nicht installiert werden: $($_.Exception.Message)" + } + } + + # PSGallery als trusted markieren, sonst kommt fuer jede Install-Aktion ein Prompt + $gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue + if ($gallery -and $gallery.InstallationPolicy -ne 'Trusted') { + try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } catch {} + } + + if (-not $AutoInstall) { + Write-Host "" + Write-Host "Folgendes wird im User-Scope (CurrentUser) installiert/aktualisiert:" -ForegroundColor Cyan + foreach ($m in $missing) { Write-Host " + $($m.Name) min. $($m.MinVersion)" -ForegroundColor White } + foreach ($o in $outdated) { Write-Host " ~ $($o.Name) v$($o.Have) -> min. $($o.Need)" -ForegroundColor White } + Write-Host "" + $answer = Read-Host "Jetzt automatisch installieren? [J]a / [N]ein" + if ($answer -notmatch '^(j|J|y|Y)') { + Write-Host "" + Write-Host "Abbruch. Du kannst die Module manuell installieren:" -ForegroundColor Yellow + foreach ($m in $missing) { Write-Host " Install-Module $($m.Name) -Scope CurrentUser -Force" -ForegroundColor Gray } + foreach ($o in $outdated) { Write-Host " Update-Module $($o.Name) -Scope CurrentUser -Force" -ForegroundColor Gray } + return $false + } + } + + foreach ($m in $missing) { + Write-Host "" + Write-Host "Installiere $($m.Name) (CurrentUser)..." -ForegroundColor Cyan + try { + Install-Module -Name $m.Name -MinimumVersion $m.MinVersion -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + Write-Host " -> OK" -ForegroundColor Green + } catch { + Write-Host " -> Fehler: $($_.Exception.Message)" -ForegroundColor Red + return $false + } + } + foreach ($o in $outdated) { + Write-Host "" + Write-Host "Aktualisiere $($o.Name) auf min. $($o.Need)..." -ForegroundColor Cyan + try { + Install-Module -Name $o.Name -MinimumVersion $o.Need -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + Write-Host " -> OK" -ForegroundColor Green + } catch { + Write-Host " -> Fehler: $($_.Exception.Message)" -ForegroundColor Red + return $false + } + } + + return $true +} + +if (-not $SkipModuleCheck) { + $required = @( + @{ Name = "Microsoft.Graph.Authentication"; MinVersion = "2.0.0" } + ) + if (-not (Test-RequiredModules -Required $required -AutoInstall:$AutoInstall)) { + Write-Host "" + Write-Host "Server-Start abgebrochen — benoetigte Module fehlen." -ForegroundColor Red + exit 1 + } + Write-Host "" +} + +# Quellmodule laden (Reihenfolge wichtig) +. (Join-Path $root "src/Models.ps1") +. (Join-Path $root "src/Graph.ps1") +. (Join-Path $root "src/Api.ps1") +. (Join-Path $root "src/Router.ps1") +. (Join-Path $root "src/Server.ps1") + +# Settings aus Persistenz (Datei) laden, optional per Parameter ueberschreiben +$script:Settings = Read-Settings +if ($TenantId) { $script:Settings.connection.tenantId = $TenantId } +if ($ClientId) { $script:Settings.connection.clientId = $ClientId } + +# Globale Konfiguration. TenantId/ClientId/Scopes spiegeln die Settings — +# Connect-Endpoint und Co. lesen weiterhin $script:Config, das nach jedem +# Settings-Save aktualisiert wird. +$script:Config = @{ + TenantId = $script:Settings.connection.tenantId + ClientId = $script:Settings.connection.clientId + Scopes = @($script:Settings.connection.scopes) + WebRoot = (Join-Path $root "www") + ReportDir = (Join-Path $env:TEMP "IntuneAppManager-Reports") +} + +if (-not (Test-Path $script:Config.ReportDir)) { + New-Item -ItemType Directory -Path $script:Config.ReportDir -Force | Out-Null +} + +# Session-State (im PowerShell-Prozess gehalten) +$script:State = [pscustomobject]@{ + Connected = $false + Account = $null + TenantId = $null + Groups = @() # abt-hm Gruppen + RpaGroups = @() + Apps = @() + Users = @{} # cache by id + GroupMembers = @{} # cache groupId -> member ids + Session = @() # geplante Zuweisungen +} + +$script:ToolVersion = "0.1.23" +$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + +Write-Host "" +Write-Host "==============================================" -ForegroundColor Cyan +Write-Host " Intune App-Zuweisungs-Manager - Web Edition" -ForegroundColor Cyan +Write-Host " Version: $script:ToolVersion" -ForegroundColor Green +Write-Host " BUILD: $script:BuildStamp" -ForegroundColor DarkGray +Write-Host "==============================================" -ForegroundColor Cyan +Write-Host "" + +# ============================================================ +# Alte Instanzen beenden: laeuft das Tool bereits, haelt der alte Prozess den +# Port (HttpListener via http.sys) — ein zweiter Start scheitert dann still und +# das alte Fenster bedient weiter mit altem Code. Darum alle anderen Start.ps1- +# Prozesse DIESES Pfads vorher beenden, damit ein Neustart wirklich neu laedt. +# ============================================================ +try { + $thisScript = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($thisScript)) { $thisScript = Join-Path $PSScriptRoot 'Start.ps1' } + # Nur echte Server-Instanzen treffen: per -File gestartet UND auf diesen + # Start.ps1-Pfad zeigend. So werden -Command-Prozesse (Diagnose etc.), die + # den Pfad nur als String enthalten, NICHT versehentlich beendet. + $stale = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -like "*$thisScript*") -and ($_.CommandLine -like "*-File*") }) + foreach ($p in $stale) { + Write-Host " Beende alte Instanz (PID $($p.ProcessId))..." -ForegroundColor Yellow + try { Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop } + catch { Write-Host " konnte PID $($p.ProcessId) nicht beenden: $($_.Exception.Message)" -ForegroundColor DarkYellow } + } + if ($stale.Count -gt 0) { Start-Sleep -Milliseconds 800 } # kurz warten bis der Port frei ist +} catch { + Write-Host " (Konnte alte Instanzen nicht pruefen: $($_.Exception.Message))" -ForegroundColor DarkGray +} + +$url = "http://localhost:$Port/" +Write-Host " URL: $url" -ForegroundColor Green +Write-Host " WebRoot: $($script:Config.WebRoot)" -ForegroundColor DarkGray +Write-Host " Reports: $($script:Config.ReportDir)" -ForegroundColor DarkGray +Write-Host " Beenden: Ctrl+C" -ForegroundColor DarkGray +Write-Host "" + +if (-not $NoBrowser) { + Start-Process $url +} + +Start-WebServer -Port $Port diff --git a/Intune/Intune-App-Manager-Web/application.ico b/Intune/Intune-App-Manager-Web/application.ico new file mode 100644 index 0000000..98f4b8b Binary files /dev/null and b/Intune/Intune-App-Manager-Web/application.ico differ diff --git a/Intune/Intune-App-Manager-Web/application.png b/Intune/Intune-App-Manager-Web/application.png new file mode 100644 index 0000000..9ae08ae Binary files /dev/null and b/Intune/Intune-App-Manager-Web/application.png differ diff --git a/Intune/Intune-App-Manager-Web/branding-logo.png b/Intune/Intune-App-Manager-Web/branding-logo.png new file mode 100644 index 0000000..9ae08ae Binary files /dev/null and b/Intune/Intune-App-Manager-Web/branding-logo.png differ diff --git a/Intune/Intune-App-Manager-Web/intune.png b/Intune/Intune-App-Manager-Web/intune.png new file mode 100644 index 0000000..d583feb Binary files /dev/null and b/Intune/Intune-App-Manager-Web/intune.png differ diff --git a/Intune/Intune-App-Manager-Web/pmpc.png b/Intune/Intune-App-Manager-Web/pmpc.png new file mode 100644 index 0000000..cd25474 Binary files /dev/null and b/Intune/Intune-App-Manager-Web/pmpc.png differ diff --git a/Intune/Intune-App-Manager-Web/robopack.png b/Intune/Intune-App-Manager-Web/robopack.png new file mode 100644 index 0000000..9c0d762 Binary files /dev/null and b/Intune/Intune-App-Manager-Web/robopack.png differ diff --git a/Intune/Intune-App-Manager-Web/src/Api.ps1 b/Intune/Intune-App-Manager-Web/src/Api.ps1 new file mode 100644 index 0000000..6f9f381 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/src/Api.ps1 @@ -0,0 +1,2613 @@ +# API-Endpoint-Handler +# Routing-Konvention: $Path startet mit /api/ und wird hier gematched. + +function Invoke-ApiHandler { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][string]$Method, + $Body, + $Query + ) + + $key = "$Method $Path" + switch ($key) { + "GET /api/ping" { return @{ pong = $true; time = (Get-Date).ToString("HH:mm:ss.fff") } } + "GET /api/version" { return @{ version = $script:ToolVersion; build = $script:BuildStamp } } + "GET /api/status" { return Get-StatusEndpoint } + "GET /api/config" { return Get-ConfigEndpoint } + "GET /api/settings" { return Get-SettingsEndpoint } + "PUT /api/settings" { return Save-SettingsEndpoint -Body $Body } + "POST /api/settings/reset" { return Reset-SettingsEndpoint } + "POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body } + "POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body } + "DELETE /api/settings/logo" { return Remove-LogoEndpoint } + "POST /api/connect" { return Invoke-ConnectEndpoint } + "POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body } + "POST /api/connect/start" { return Start-DeviceCodeConnect } + "POST /api/connect/cancel" { Stop-ConnectFlow; return @{ ok = $true } } + "POST /api/disconnect" { return Invoke-DisconnectEndpoint } + + "GET /api/groups" { return Get-GroupsEndpoint -Query $Query } + "GET /api/groups/rpa" { return Get-RpaGroupsEndpoint } + "POST /api/groups" { return New-GroupEndpoint -Body $Body } + "POST /api/groups/check" { return Test-GroupNameEndpoint -Body $Body } + + "GET /api/users" { return Search-UsersEndpoint -Query $Query } + + "GET /api/apps" { return Get-AppsEndpoint -Query $Query } + "GET /api/apps/categories" { return Get-AppCategoriesEndpoint } + "POST /api/apps/refresh" { return Get-AppsEndpoint -Query @{ refresh = "true" } } + + "GET /api/membership" { return Get-MembershipEndpoint -Query $Query } + "POST /api/membership/bulk" { return Get-MembershipBulkEndpoint -Body $Body } + + "POST /api/assignments/apply" { return Invoke-ApplyEndpoint -Body $Body } + } + + # 2-segment fallbacks (z.B. /api/groups//members) + if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") { + return Get-GroupMembersEndpoint -GroupId $matches[1] + } + + if ($Method -eq "GET" -and $Path -match "^/api/apps/([^/]+)/details$") { + return Get-AppDetailsEndpoint -AppId $matches[1] + } + + if ($Method -eq "DELETE" -and $Path -match "^/api/apps/([^/]+)$") { + return Remove-AppEndpoint -AppId $matches[1] + } + + if ($Method -eq "PATCH" -and $Path -match "^/api/apps/([^/]+)$") { + return Update-AppEndpoint -AppId $matches[1] -Body $Body + } + + if ($Method -eq "DELETE" -and $Path -match "^/api/apps/([^/]+)/assignments/([^/]+)$") { + return Remove-AppAssignmentEndpoint -AppId $matches[1] -GroupId $matches[2] -Query $Query + } + + if ($Method -eq "POST" -and $Path -match "^/api/apps/([^/]+)/assignments$") { + return Add-AppAssignmentEndpoint -AppId $matches[1] -Body $Body + } + + if ($Method -eq "POST" -and $Path -match "^/api/apps/([^/]+)/content$") { + return Update-AppContentEndpoint -AppId $matches[1] -Body $Body + } + + if ($Method -eq "POST" -and $Path -eq "/api/pickfile") { + return Invoke-FilePickerEndpoint -Body $Body + } + + return $null +} + +# ============================================================ +# Status & Connection +# ============================================================ + +function Get-ConfigEndpoint { + return @{ + tenantId = $script:Config.TenantId + clientId = $script:Config.ClientId + scopes = $script:Config.Scopes + } +} + +# ============================================================ +# Settings: lesen / schreiben / zuruecksetzen +# ============================================================ + +function Get-SettingsEndpoint { + # cacheTag fuer das Logo: nutzt File-Mtime — so kann das Frontend den + # Browser-Cache zuverlaessig brechen, unabhaengig davon ob das Logo gerade + # erst hochgeladen oder die Seite frisch geladen wurde. + $logoCacheTag = $null + if ($script:Settings.branding -and $script:Settings.branding.logoFile) { + $logoPath = Join-Path (Get-BrandingDir) $script:Settings.branding.logoFile + if (Test-Path $logoPath -PathType Leaf) { + $logoCacheTag = [DateTimeOffset]::new((Get-Item $logoPath).LastWriteTimeUtc).ToUnixTimeSeconds() + } else { + # Datei verschwunden -> Setting zuruecksetzen damit das Frontend + # nicht versucht ein 404-Image zu rendern + $script:Settings.branding.logoFile = $null + } + } + + # Klon des Settings-Objekts mit branding.logoCacheTag — damit kein + # zusaetzlicher Outer-Key noetig ist und das Frontend einheitlich nur + # 'branding' liest. + $settings = $script:Settings | ConvertTo-Json -Depth 10 | ConvertFrom-Json + if (-not $settings.branding) { + $settings | Add-Member -NotePropertyName 'branding' -NotePropertyValue ([pscustomobject]@{ logoFile = $null; logoCacheTag = $null }) -Force + } else { + $settings.branding | Add-Member -NotePropertyName 'logoCacheTag' -NotePropertyValue $logoCacheTag -Force + } + return @{ settings = $settings; path = (Get-SettingsPath) } +} + +function Sync-ConfigFromSettings { + # $script:Config spiegelt die settings.connection-Werte. Wird nach jedem + # Save aufgerufen damit Connect-Aufrufe sofort die neuen IDs verwenden. + $script:Config.TenantId = $script:Settings.connection.tenantId + $script:Config.ClientId = $script:Settings.connection.clientId + $script:Config.Scopes = @($script:Settings.connection.scopes) +} + +function Save-SettingsEndpoint { + param($Body) + if (-not $Body) { + return @{ __status = 400; error = "Request-Body fehlt" } + } + + # Body kann hashtable (vom Router) oder pscustomobject sein -> normalisieren + $incoming = $Body + if ($incoming -is [hashtable]) { + $incoming = $incoming | ConvertTo-Json -Depth 10 | ConvertFrom-Json + } + + # Mit aktuellen Settings mergen, damit ueberspringbare Sub-Sektionen aus dem + # Frontend nichts ueberschreiben was nicht mitgeschickt wurde. + $merged = Merge-Settings -Base $script:Settings -Override $incoming + + $errs = Get-SettingsValidationErrors -S $merged + if ($errs.Count -gt 0) { + return @{ __status = 400; error = ($errs -join " | "); errors = $errs } + } + + # Vergleich altes vs. neues Setting — Cache nur leeren wo wirklich noetig. + $old = $script:Settings + + $connectionChanged = ( + $merged.connection.tenantId -ne $old.connection.tenantId -or + $merged.connection.clientId -ne $old.connection.clientId -or + (($merged.connection.scopes -join "|") -ne ($old.connection.scopes -join "|")) + ) + # Normalisierte Praefix-Listen vergleichen (deckt sowohl 'prefixes' als auch + # den alten Single-String 'prefix' ab; Reihenfolge ignoriert, Case ignoriert). + $deptOld = @(Get-DepartmentPrefixes -Settings $old) | Sort-Object -Property { $_.ToLowerInvariant() } + $deptNew = @(Get-DepartmentPrefixes -Settings $merged) | Sort-Object -Property { $_.ToLowerInvariant() } + $deptChanged = (($deptOld -join '|') -ne ($deptNew -join '|')) + $rpaChanged = (($merged.rpa.groupNames -join "|") -ne ($old.rpa.groupNames -join "|")) + $userSearchChanged = ( + (($merged.userSearch.fields -join "|") -ne ($old.userSearch.fields -join "|")) + ) + # requiredGroupNaming + theme + branding sind reine UI-/Workflow-Werte — + # die brechen keine Backend-Caches. + + $script:Settings = $merged + Sync-ConfigFromSettings + try { + Write-Settings -Settings $script:Settings + } catch { + return @{ __status = 500; error = "Speichern fehlgeschlagen: $($_.Exception.Message)" } + } + + if ($connectionChanged -and $script:State.Connected) { + Write-Host "[SETTINGS] Connection-Werte geaendert -> Disconnect" -ForegroundColor Yellow + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + $script:State.Connected = $false + $script:State.Account = $null + $script:State.TenantId = $null + # Bei neuem Tenant ist ALLES potentiell anders — alle Caches weg. + $script:State.Groups = @() + $script:State.RpaGroups = @() + $script:State.Apps = @() + $script:State.GroupMembers = @{} + } else { + # Selektiv: nur die Caches leeren, deren Quelle sich tatsaechlich + # geaendert hat. + if ($deptChanged) { $script:State.Groups = @() } + if ($rpaChanged) { $script:State.RpaGroups = @() } + } + + $changed = @{ + connection = [bool]$connectionChanged + departments = [bool]$deptChanged + rpa = [bool]$rpaChanged + userSearch = [bool]$userSearchChanged + # Diese muss das Frontend lokal anwenden, kein Backend-Cache-Reset noetig: + branding = (($merged.branding.logoFile) -ne ($old.branding.logoFile)) + theme = (($merged.theme.colors | ConvertTo-Json -Compress) -ne ($old.theme.colors | ConvertTo-Json -Compress)) + requiredGroupNaming = ($merged.requiredGroupNaming.prefix -ne $old.requiredGroupNaming.prefix -or $merged.requiredGroupNaming.suffix -ne $old.requiredGroupNaming.suffix) + } + + Write-Host ("[SETTINGS] geaendert: " + (($changed.GetEnumerator() | Where-Object { $_.Value }) | ForEach-Object { $_.Key } | Sort-Object) -join ', ') -ForegroundColor DarkGray + + return @{ + ok = $true + settings = $script:Settings + disconnected = [bool]$connectionChanged + changed = $changed + } +} + +function Reset-SettingsEndpoint { + $script:Settings = Get-DefaultSettings + Sync-ConfigFromSettings + try { + Write-Settings -Settings $script:Settings + } catch { + return @{ __status = 500; error = "Reset fehlgeschlagen: $($_.Exception.Message)" } + } + if ($script:State.Connected) { + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + $script:State.Connected = $false + $script:State.Account = $null + $script:State.TenantId = $null + } + $script:State.Groups = @() + $script:State.RpaGroups = @() + $script:State.Apps = @() + $script:State.GroupMembers = @{} + return @{ ok = $true; settings = $script:Settings; disconnected = $true } +} + +function Test-SettingsEndpoint { + # Prueft die im Body uebergebenen (oder, falls leer, die aktuell gespeicherten) + # Settings gegen Microsoft Graph. Liefert pro Pruefung ok/Trefferzahl/Fehler. + # Aenderungen werden NICHT gespeichert — nur ein temporaerer Swap fuers Lookup. + param($Body) + + $err = Test-Connected + if ($err) { + return @{ + ok = $false + connection = @{ ok = $false; reason = "not_connected"; message = "Bitte zuerst per Connect-Button verbinden." } + } + } + + # Body normalisieren (Hashtable -> PSCustomObject) und mit aktuellen Settings mergen. + $incoming = $Body + if ($incoming -is [hashtable]) { + $incoming = $incoming | ConvertTo-Json -Depth 10 | ConvertFrom-Json + } + $effective = if ($incoming) { Merge-Settings -Base $script:Settings -Override $incoming } else { $script:Settings } + + # Settings nur fuer die Dauer des Tests umschalten — Search-GraphUser greift + # auf $script:Settings.userSearch.fields zu. Im finally garantiert zuruecksetzen. + $original = $script:Settings + $script:Settings = $effective + + $result = @{ + ok = $true + connection = @{ ok = $true; tenantId = $script:State.TenantId; account = $script:State.Account } + } + + try { + # 1) Abteilungs-Lookup pro konfiguriertem Praefix + $prefixes = @(Get-DepartmentPrefixes -Settings $effective) + if ($prefixes.Count -eq 0) { + $result.departments = @{ ok = $false; reason = "not_configured"; message = "Kein Abteilungs-Praefix gesetzt." } + } else { + $perPrefix = @() + $totalCount = 0 + $allOk = $true + foreach ($p in $prefixes) { + try { + $groups = @(Get-GraphGroupByFilter -Filter "startswith(displayName,'$p')" -Property @("id","displayName")) + $sample = @($groups | Select-Object -First 3 | ForEach-Object { if ($_.displayName) { $_.displayName } else { $_.displayname } }) + $perPrefix += @{ prefix = $p; ok = $true; count = $groups.Count; sample = $sample } + $totalCount += $groups.Count + } catch { + $perPrefix += @{ prefix = $p; ok = $false; error = $_.Exception.Message } + $allOk = $false + } + } + $result.departments = @{ + ok = $allOk + prefixes = $prefixes + totalCount = $totalCount + perPrefix = $perPrefix + } + } + + # 2) RPA-Gruppen-Lookup + $rpaNames = @($effective.rpa.groupNames | Where-Object { $_ -and $_.Trim() }) + if ($rpaNames.Count -eq 0) { + $result.rpa = @{ ok = $false; reason = "not_configured"; message = "Keine RPA-Gruppen konfiguriert." } + } else { + $found = @() + $missing = @() + foreach ($n in $rpaNames) { + try { + $g = @(Get-GraphGroupByFilter -Filter "displayName eq '$n'" -Property @("id","displayName")) + if ($g.Count -gt 0) { $found += $n } else { $missing += $n } + } catch { + $missing += $n + } + } + $result.rpa = @{ + ok = ($missing.Count -eq 0) + expected = $rpaNames.Count + found = $found + missing = $missing + } + } + + # 3) User-Such-Probe — sehr kurzer Sondierungs-Request + $fields = @($effective.userSearch.fields | Where-Object { $_ }) + if ($fields.Count -eq 0) { + $result.userSearch = @{ ok = $false; reason = "no_fields"; message = "Mindestens ein Such-Feld waehlen." } + } else { + try { + # Such-Term "a" -> trifft praktisch immer mind. einen User, schnell genug. + $hits = @(Search-GraphUser -SearchTerm "a") + $result.userSearch = @{ ok = $true; fields = $fields; sampleCount = [Math]::Min($hits.Count, 10) } + } catch { + $result.userSearch = @{ ok = $false; fields = $fields; error = $_.Exception.Message } + } + } + + # 4) Vendor-Match-Counts — pro konfiguriertem Vendor zaehlen wie viele + # gecachte Apps unter dessen Detection-Regel fallen. 0 Treffer = die + # Regel greift nicht (Hinweis im UI). + $vendorList = @($effective.vendors) + if ($vendorList.Count -eq 0) { + $result.vendors = @{ ok = $true; configured = 0; counts = @() } + } else { + $counts = @() + foreach ($v in $vendorList) { + $count = 0 + if ($script:State.Apps -and $script:State.Apps.Count -gt 0) { + $count = @($script:State.Apps | Where-Object { $_.Source -eq $v.displayName }).Count + } + $counts += [pscustomobject]@{ + id = $v.id + displayName = $v.displayName + count = $count + detection = "$($v.detection.field) $($v.detection.match) '$($v.detection.pattern)'" + } + } + $allZero = -not ($counts | Where-Object { $_.count -gt 0 }) + $result.vendors = @{ + ok = $true + configured = $vendorList.Count + counts = $counts + hint = if ($allZero -and $script:State.Apps.Count -eq 0) { "Apps wurden noch nicht geladen — Counts sind 0." } else { $null } + } + } + } finally { + $script:Settings = $original + } + + # Gesamt-OK = alle Sub-Checks ok (Vendors sind informativ, schlagen nicht fehl) + $result.ok = ($result.departments.ok -and $result.rpa.ok -and $result.userSearch.ok) + return $result +} + +# ============================================================ +# Branding: Logo hochladen / loeschen +# ============================================================ + +# Wo Logos landen — gleiches Verzeichnis wie intune.png/pmpc.png, ausgeliefert +# ueber die vorhandene /assets/-Route. +function Get-BrandingDir { + return Split-Path -Parent $script:Config.WebRoot +} + +# Branding-Logos haben einen festen Praefix, damit wir alte Versionen sauber +# loeschen koennen wenn die Extension wechselt. +$script:BrandingLogoPrefix = 'branding-logo' + +function Remove-BrandingLogoFiles { + $dir = Get-BrandingDir + Get-ChildItem -Path $dir -Filter "$($script:BrandingLogoPrefix).*" -File -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue } +} + +function Save-LogoEndpoint { + param($Body) + if (-not $Body) { return @{ __status = 400; error = "Body fehlt" } } + + $mime = [string]$Body.mime + $dataBase64 = [string]$Body.dataBase64 + + if (-not $dataBase64) { return @{ __status = 400; error = "dataBase64 fehlt" } } + if ($mime -notmatch '^image/') { return @{ __status = 400; error = "Datei muss ein Bild sein (mime: $mime)" } } + + $ext = switch -Regex ($mime) { + 'png$' { 'png'; break } + 'jpe?g$' { 'jpg'; break } + 'svg' { 'svg'; break } + 'webp' { 'webp'; break } + 'gif' { 'gif'; break } + default { $null } + } + if (-not $ext) { return @{ __status = 400; error = "Bildformat nicht unterstuetzt: $mime" } } + + try { + $bytes = [Convert]::FromBase64String($dataBase64) + } catch { + return @{ __status = 400; error = "Base64 ungueltig: $($_.Exception.Message)" } + } + + $maxBytes = 2 * 1024 * 1024 # 2 MB + if ($bytes.Length -gt $maxBytes) { + return @{ __status = 413; error = "Logo zu gross ($([int]($bytes.Length / 1024)) KB, max. 2 MB)" } + } + if ($bytes.Length -lt 4) { + return @{ __status = 400; error = "Datei zu klein / leer" } + } + + $fileName = "$($script:BrandingLogoPrefix).$ext" + $dir = Get-BrandingDir + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + + # Alte Logo-Varianten weg, dann neue Datei schreiben + Remove-BrandingLogoFiles + $target = Join-Path $dir $fileName + [IO.File]::WriteAllBytes($target, $bytes) + + if (-not $script:Settings.branding) { $script:Settings | Add-Member -NotePropertyName 'branding' -NotePropertyValue ([pscustomobject]@{}) -Force } + $script:Settings.branding.logoFile = $fileName + try { Write-Settings -Settings $script:Settings } catch { + return @{ __status = 500; error = "Settings-Speichern fehlgeschlagen: $($_.Exception.Message)" } + } + + Write-Host "[LOGO] Gespeichert: $target ($([int]($bytes.Length / 1024)) KB)" -ForegroundColor Green + $mtime = [DateTimeOffset]::new((Get-Item $target).LastWriteTimeUtc).ToUnixTimeSeconds() + return @{ + ok = $true + logoFile = $fileName + sizeKb = [int]($bytes.Length / 1024) + # Cache-Bust-Tag = File-Mtime, identisch zu dem was Get-Settings liefert. + cacheTag = $mtime + } +} + +function Remove-LogoEndpoint { + Remove-BrandingLogoFiles + if ($script:Settings.branding) { + $script:Settings.branding.logoFile = $null + } + try { Write-Settings -Settings $script:Settings } catch { + return @{ __status = 500; error = "Settings-Speichern fehlgeschlagen: $($_.Exception.Message)" } + } + Write-Host "[LOGO] Entfernt" -ForegroundColor DarkGray + return @{ ok = $true } +} + +function Invoke-ConnectWithTokenEndpoint { + param($Body) + try { Initialize-GraphModule } catch { + return @{ __status = 500; error = "Microsoft.Graph.Authentication fehlt: $($_.Exception.Message)" } + } + + $token = $Body.accessToken + if ([string]::IsNullOrWhiteSpace($token)) { + return @{ __status = 400; error = "accessToken fehlt im Body" } + } + + Write-Host "[CONNECT] Token-Login fuer Account: $($Body.account)" -ForegroundColor Cyan + + # Eventuell aktive Session beenden + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + + try { + # Microsoft.Graph.Authentication v2+ erwartet SecureString + $secure = ConvertTo-SecureString $token -AsPlainText -Force + Connect-MgGraph -AccessToken $secure -NoWelcome -ErrorAction Stop | Out-Null + } catch { + # Fallback fuer aeltere Modul-Versionen die Plain-String akzeptieren + try { + Connect-MgGraph -AccessToken $token -NoWelcome -ErrorAction Stop | Out-Null + } catch { + $msg = $_.Exception.Message + Write-Host "[CONNECT] Token-Login fehlgeschlagen: $msg" -ForegroundColor Red + return @{ __status = 500; error = "Connect-MgGraph mit Token fehlgeschlagen: $msg" } + } + } + + $ctx = $null + try { $ctx = Get-MgContext } catch {} + if (-not $ctx -or -not $ctx.Account) { + return @{ __status = 500; error = "Kein Kontext nach Token-Login (ungueltiger oder abgelaufener Token?)" } + } + + $script:State.Connected = $true + $script:State.Account = $ctx.Account + $script:State.TenantId = $ctx.TenantId + Write-Host "[CONNECT] OK via Token - Account: $($ctx.Account), Tenant: $($ctx.TenantId)" -ForegroundColor Green + + return Get-StatusEndpoint +} + +function Get-StatusEndpoint { + $cs = $script:ConnectState + $connect = $null + if ($cs -and ($cs.Active -or $cs.Error) -and -not $script:State.Connected) { + $connect = @{ + active = [bool]$cs.Active + done = [bool]$cs.Done + error = $cs.Error + } + } + return @{ + connected = $script:State.Connected + account = $script:State.Account + tenantId = $script:State.TenantId + groupsLoaded = $script:State.Groups.Count + rpaLoaded = $script:State.RpaGroups.Count + appsLoaded = $script:State.Apps.Count + sessionCount = $script:State.Session.Count + connect = $connect + } +} + +# ============================================================ +# Device-Code-Flow: Login direkt in der Website, ohne WAM, mit +# voller Account-Kontrolle. Laeuft in Background-Runspace, damit +# der HTTP-Listener waehrend des Logins nicht blockiert. +# ============================================================ + +function Get-DeviceCodeState { + if (-not $script:DeviceCodeState) { + $script:DeviceCodeState = [hashtable]::Synchronized(@{ + Active = $false + Code = $null + Url = $null + UrlComplete = $null + DeviceCode = $null + ExpiresAt = $null + Done = $false + Error = $null + Runspace = $null + PowerShell = $null + }) + } + return $script:DeviceCodeState +} + +function Start-DeviceCodeConnect { + try { Initialize-GraphModule } catch { + return @{ __status = 500; error = "Microsoft.Graph.Authentication fehlt: $($_.Exception.Message)" } + } + + $dc = Get-DeviceCodeState + + # Idempotenz: laufender Code wird wieder zurueckgegeben + if ($dc.Active -and -not $dc.Done -and $dc.Code) { + return @{ + code = $dc.Code + url = $dc.Url + urlComplete = $dc.UrlComplete + existing = $true + } + } + + # Vorherige Session aufraeumen + Stop-DeviceCodeConnect | Out-Null + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + $script:State.Connected = $false + $script:State.Account = $null + + $tenantId = $script:Config.TenantId + $clientId = $script:Config.ClientId + $scopeStr = (($script:Config.Scopes | ForEach-Object { "https://graph.microsoft.com/$_" }) + 'offline_access') -join ' ' + + # 1) Device-Code direkt von Microsoft holen (synchron, schnell) + Write-Host "[CONNECT] Hole Device-Code von Microsoft..." -ForegroundColor DarkGray + try { + $body = @{ client_id = $clientId; scope = $scopeStr } + $resp = Invoke-RestMethod ` + -Method POST ` + -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/devicecode" ` + -Body $body ` + -ContentType 'application/x-www-form-urlencoded' ` + -ErrorAction Stop + } catch { + $msg = $_.Exception.Message + Write-Host "[CONNECT] Device-Code-Anforderung fehlgeschlagen: $msg" -ForegroundColor Red + return @{ __status = 500; error = "Device-Code anfordern fehlgeschlagen: $msg" } + } + + $dc.Active = $true + $dc.Code = $resp.user_code + $dc.Url = $resp.verification_uri + $dc.UrlComplete = if ($resp.verification_uri_complete) { $resp.verification_uri_complete } else { "$($resp.verification_uri)?otc=$($resp.user_code)" } + $dc.DeviceCode = $resp.device_code + $dc.ExpiresAt = (Get-Date).AddSeconds([int]$resp.expires_in) + $dc.Done = $false + $dc.Error = $null + + Write-Host "[CONNECT] Code: $($resp.user_code) - Url: $($dc.UrlComplete)" -ForegroundColor Cyan + + # 2) Hintergrund-Runspace pollt das Token-Endpoint + $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() + $iss.ImportPSModule("Microsoft.Graph.Authentication") + $rs = [runspacefactory]::CreateRunspace($iss) + $rs.Open() + $rs.SessionStateProxy.SetVariable("DeviceCode", $dc) + $rs.SessionStateProxy.SetVariable("MainState", $script:State) + $rs.SessionStateProxy.SetVariable("PollTenantId", $tenantId) + $rs.SessionStateProxy.SetVariable("PollClientId", $clientId) + $rs.SessionStateProxy.SetVariable("PollInterval", [int]$resp.interval) + + $ps = [powershell]::Create() + $ps.Runspace = $rs + $null = $ps.AddScript({ + $tokenUri = "https://login.microsoftonline.com/$PollTenantId/oauth2/v2.0/token" + $body = @{ + grant_type = 'urn:ietf:params:oauth:grant-type:device_code' + client_id = $PollClientId + device_code = $DeviceCode.DeviceCode + } + + while (-not $DeviceCode.Done -and (Get-Date) -lt $DeviceCode.ExpiresAt) { + Start-Sleep -Seconds $PollInterval + if ($DeviceCode.Done) { return } # vom User abgebrochen + + try { + $tok = Invoke-RestMethod ` + -Method POST ` + -Uri $tokenUri ` + -Body $body ` + -ContentType 'application/x-www-form-urlencoded' ` + -ErrorAction Stop + + if ($tok.access_token) { + # Token bekommen, an Connect-MgGraph weiterreichen + try { + $secure = ConvertTo-SecureString $tok.access_token -AsPlainText -Force + Connect-MgGraph -AccessToken $secure -NoWelcome -ErrorAction Stop | Out-Null + } catch { + # Fallback fuer aeltere Modul-Versionen + Connect-MgGraph -AccessToken $tok.access_token -NoWelcome -ErrorAction Stop | Out-Null + } + $ctx = Get-MgContext + if ($ctx -and $ctx.Account) { + $MainState.Connected = $true + $MainState.Account = $ctx.Account + $MainState.TenantId = $ctx.TenantId + } else { + $DeviceCode.Error = "Token erhalten aber Get-MgContext leer" + } + $DeviceCode.Done = $true + $DeviceCode.Active = $false + return + } + } catch { + # Fehler-Body parsen (authorization_pending ist erwartet) + $errStr = "$($_.ErrorDetails.Message)" + if (-not $errStr) { $errStr = $_.Exception.Message } + + if ($errStr -match 'authorization_pending') { + continue # User hat noch nicht abgeschlossen, weiterpollen + } elseif ($errStr -match 'slow_down') { + Start-Sleep -Seconds 5 + continue + } elseif ($errStr -match 'authorization_declined') { + $DeviceCode.Error = 'Anmeldung wurde abgelehnt' + $DeviceCode.Done = $true + $DeviceCode.Active = $false + return + } elseif ($errStr -match 'expired_token') { + $DeviceCode.Error = 'Code ist abgelaufen - bitte neu starten' + $DeviceCode.Done = $true + $DeviceCode.Active = $false + return + } else { + # Versuche JSON-Body zu extrahieren + try { + $j = $errStr | ConvertFrom-Json + $DeviceCode.Error = "$($j.error): $($j.error_description)" + } catch { + $DeviceCode.Error = $errStr + } + $DeviceCode.Done = $true + $DeviceCode.Active = $false + return + } + } + } + + if (-not $MainState.Connected -and -not $DeviceCode.Error) { + $DeviceCode.Error = "Anmeldung abgelaufen (Code nicht eingegeben)" + } + $DeviceCode.Done = $true + $DeviceCode.Active = $false + }) + + $dc.PowerShell = $ps + $dc.Runspace = $rs + $null = $ps.BeginInvoke() + + return @{ + code = $dc.Code + url = $dc.Url + urlComplete = $dc.UrlComplete + expiresIn = [int]$resp.expires_in + existing = $false + } +} + +function Stop-DeviceCodeConnect { + $dc = Get-DeviceCodeState + if ($dc.PowerShell) { + try { $dc.PowerShell.Stop() } catch {} + try { $dc.PowerShell.Dispose() } catch {} + } + if ($dc.Runspace) { + try { $dc.Runspace.Close() } catch {} + try { $dc.Runspace.Dispose() } catch {} + } + $dc.PowerShell = $null + $dc.Runspace = $null + $dc.Active = $false + $dc.Code = $null + $dc.Url = $null + $dc.Done = $true + $dc.Error = $null + return @{ ok = $true } +} + +# ============================================================ +# Status-Endpoint kennt jetzt auch den Device-Code-Flow +# ============================================================ + +function Initialize-WinFocus { + if ('WinFocusHelper' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public class WinFocusHelper { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId); + [DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); + + public static IntPtr FindAuthWindow() { + IntPtr found = IntPtr.Zero; + string[] needles = new string[] { + "Anmelden", "Sign in", "Konto auswählen", "Pick an account", + "Authentifizierung", "Microsoft Account", "Microsoft Authentication" + }; + EnumWindows((hWnd, lParam) => { + if (!IsWindowVisible(hWnd)) return true; + StringBuilder sb = new StringBuilder(256); + GetWindowText(hWnd, sb, sb.Capacity); + string title = sb.ToString(); + if (string.IsNullOrEmpty(title)) return true; + foreach (var n in needles) { + if (title.IndexOf(n, StringComparison.OrdinalIgnoreCase) >= 0) { + found = hWnd; + return false; // stop enumerating + } + } + return true; + }, IntPtr.Zero); + return found; + } + + public static void BringToFront(IntPtr hWnd) { + if (hWnd == IntPtr.Zero) return; + ShowWindow(hWnd, 9); // SW_RESTORE + BringWindowToTop(hWnd); + SetForegroundWindow(hWnd); + } +} +'@ +} + +# Status fuer den asynchronen interaktiven Login +function Get-ConnectState { + if (-not $script:ConnectState) { + $script:ConnectState = [hashtable]::Synchronized(@{ + Active = $false + Done = $false + Error = $null + StartedAt = $null + Runspace = $null + PowerShell = $null + }) + } + return $script:ConnectState +} + +function Stop-ConnectFlow { + $cs = Get-ConnectState + if ($cs.PowerShell) { + try { $cs.PowerShell.Stop() } catch {} + try { $cs.PowerShell.Dispose() } catch {} + } + if ($cs.Runspace) { + try { $cs.Runspace.Close() } catch {} + try { $cs.Runspace.Dispose() } catch {} + } + $cs.PowerShell = $null + $cs.Runspace = $null + $cs.Active = $false +} + +function Invoke-ConnectEndpoint { + try { + Initialize-GraphModule | Out-Null + + Write-Host "[CONNECT] Verbinde mit Microsoft Graph..." -ForegroundColor Cyan + + # Parallel-Runspace, der das WAM-Account-Picker-Fenster sucht und + # nach vorne bringt — sonst landet es hinter anderen Fenstern und + # der User sieht nichts, was er bestaetigen koennte. + $bringToFront = $null + try { + Initialize-WinFocus + $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() + $rs = [runspacefactory]::CreateRunspace($iss) + $rs.Open() + $bringToFront = [powershell]::Create() + $bringToFront.Runspace = $rs + $null = $bringToFront.AddScript({ + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public class WinFocusHelper2 { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); +} +'@ -ErrorAction SilentlyContinue + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + $found = [IntPtr]::Zero + [WinFocusHelper2]::EnumWindows({ + param($hWnd, $lParam) + if (-not [WinFocusHelper2]::IsWindowVisible($hWnd)) { return $true } + $sb = New-Object System.Text.StringBuilder 256 + [void][WinFocusHelper2]::GetWindowText($hWnd, $sb, $sb.Capacity) + $t = $sb.ToString() + if ([string]::IsNullOrEmpty($t)) { return $true } + foreach ($n in @('Anmelden','Sign in','Konto','Pick an account','Authentifizierung','Microsoft Account','Microsoft Authentication')) { + if ($t.IndexOf($n, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + $script:found = $hWnd + return $false + } + } + return $true + }, [IntPtr]::Zero) | Out-Null + if ($script:found -ne [IntPtr]::Zero) { + [WinFocusHelper2]::ShowWindow($script:found, 9) | Out-Null # SW_RESTORE + [WinFocusHelper2]::BringWindowToTop($script:found) | Out-Null + [WinFocusHelper2]::SetForegroundWindow($script:found) | Out-Null + Start-Sleep -Milliseconds 800 + } + Start-Sleep -Milliseconds 400 + } + }) + $null = $bringToFront.BeginInvoke() + } catch { + Write-Host "[CONNECT] WAM-Focus-Helper konnte nicht gestartet werden: $($_.Exception.Message)" -ForegroundColor DarkYellow + } + + Connect-MgGraph ` + -TenantId $script:Config.TenantId ` + -ClientId $script:Config.ClientId ` + -Scopes $script:Config.Scopes ` + -NoWelcome + + # Helper-Runspace aufraeumen + if ($bringToFront) { + try { $bringToFront.Stop() } catch {} + try { $bringToFront.Dispose() } catch {} + } + + $context = Get-MgContext + if ($context) { + $script:State.Connected = $true + $script:State.Account = $context.Account + $script:State.TenantId = $context.TenantId + Write-Host "[CONNECT] Verbunden als: $($context.Account)" -ForegroundColor Green + return Get-StatusEndpoint + } + Write-Host "[CONNECT] Kein Context nach Connect-MgGraph" -ForegroundColor Red + return @{ __status = 401; error = "Anmeldung fehlgeschlagen — kein Context" } + } catch { + Write-Host "[CONNECT] Fehler: $_" -ForegroundColor Red + return @{ __status = 500; error = "Fehler beim Verbinden: $($_.Exception.Message)" } + } +} + +function Invoke-DisconnectEndpoint { + try { Disconnect-MgGraph | Out-Null } catch {} + $script:State.Connected = $false + $script:State.Account = $null + $script:State.TenantId = $null + $script:State.Groups = @() + $script:State.RpaGroups = @() + $script:State.Apps = @() + $script:State.Session = @() + $script:State.GroupMembers = @{} + return Get-StatusEndpoint +} + +function Test-Connected { + if (-not $script:State.Connected) { + return @{ __status = 401; error = "Nicht mit Microsoft Graph verbunden" } + } + return $null +} + +function Get-AppCategoryNames { + # Extrahiert die Kategorie-Namen aus dem rohen Graph-Categories-Feld. + # Robust gegen alle Source-Types die MgGraph/Invoke-MgGraphRequest in + # PS 5.1 + PS 7 liefern kann (PSCustomObject, Hashtable, generische + # Dictionary, Array von Strings, Skalar). Loggt im Server-Console wenn + # Items leer durchrutschen — dann sehen wir live was schief geht. + param($RawCategories, [string]$AppId) + + # Wichtig: IMMER ein Array zurueckgeben (auch leer), nicht $null. + # @() wrap am Aufruf-Site reicht in PS5.1 oft nicht — explizite Liste. + $names = [System.Collections.Generic.List[string]]::new() + + if ($null -eq $RawCategories) { return ,$names.ToArray() } + + $items = @($RawCategories) + if ($items.Count -eq 0) { return ,$names.ToArray() } + + $typeNames = ($items | ForEach-Object { if ($null -eq $_) { 'null' } else { $_.GetType().Name } }) -join ', ' + Write-Host " [CATS] ${AppId}: $($items.Count) Roh-Items, Types: $typeNames" -ForegroundColor DarkGray + + foreach ($cat in $items) { + if ($null -eq $cat) { continue } + if ($cat -is [string]) { + if ($cat) { $names.Add($cat) } + continue + } + + $n = $null + + # Direkt-Dot-Access (PSCustomObject + PS-7-Hashtable + Dictionary) + try { if ($cat.displayName) { $n = [string]$cat.displayName } } catch {} + if (-not $n) { try { if ($cat.DisplayName) { $n = [string]$cat.DisplayName } } catch {} } + + # Hashtable-Indexer (PS 5.1 Hashtable) + if (-not $n) { + try { + if ($cat -is [System.Collections.IDictionary]) { + foreach ($k in 'displayName','DisplayName','name','Name') { + if ($cat.Contains($k) -and $cat[$k]) { $n = [string]$cat[$k]; break } + } + } + } catch {} + } + + # Letzter Versuch: JSON-Roundtrip + if (-not $n) { + try { + $obj = $cat | ConvertTo-Json -Depth 3 -Compress | ConvertFrom-Json + foreach ($k in 'displayName','DisplayName','name','Name') { + try { if ($obj.$k) { $n = [string]$obj.$k; break } } catch {} + } + } catch {} + } + + if ($n) { + $names.Add($n) + } else { + $dump = $null + try { $dump = $cat | ConvertTo-Json -Depth 2 -Compress } catch { $dump = $cat.GetType().FullName } + Write-Host " [CATS] ${AppId}: konnte Name nicht extrahieren aus: $dump" -ForegroundColor Yellow + } + } + + # Komma vor $names.ToArray() forciert dass PowerShell IMMER ein Array + # zurueckgibt — auch bei genau 1 Element (sonst Skalar = JSON-Fehler). + return ,$names.ToArray() +} + +function Find-VendorBySource { + # Liefert den Vendor-Eintrag aus Settings, dessen displayName mit dem + # Source-String einer App uebereinstimmt. $null wenn nichts passt + # (z.B. Source = "Intune"). + param([string]$Source) + if (-not $Source -or $Source -eq 'Intune') { return $null } + if (-not $script:Settings.vendors) { return $null } + return @($script:Settings.vendors | Where-Object { $_.displayName -eq $Source } | Select-Object -First 1)[0] +} + +# ============================================================ +# Gruppen +# ============================================================ + +function Get-GroupsEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + + # Query-Override hat Vorrang (Debug-Pfad); sonst alle konfigurierten Praefixe. + if ($Query.prefix) { + $prefixes = @([string]$Query.prefix) + } else { + $prefixes = @(Get-DepartmentPrefixes -Settings $script:Settings) + } + if ($prefixes.Count -eq 0) { + return @{ + __status = 412 + error = "Abteilungs-Praefix(e) nicht konfiguriert. Bitte unter Einstellungen -> Abteilungs-Gruppen setzen." + code = "SettingsRequired" + setting = "departments.prefixes" + } + } + # OR-verketteter Graph-$filter: alle Praefixe in einem Listen-Request laden. + # Graph erlaubt mehrfache startswith-Klauseln per 'or'. + $parts = @($prefixes | ForEach-Object { "startswith(displayName,'$($_)')" }) + $filter = $parts -join " or " + $raw = Get-GraphGroupByFilter -Filter $filter -Property @("id","displayName") -ExpandMembers + + $items = @() + foreach ($g in $raw) { + $id = if ($g.id) { $g.id } else { $g.Id } + $name = if ($g.displayName) { $g.displayName } else { $g.displayname } + $members = if ($null -ne $g.members) { $g.members } else { $g.Members } + $hasMembers = ($members -is [array] -and $members.Count -gt 0) -or ($null -ne $members -and -not ($members -is [array])) + if ($id) { + $items += [pscustomobject]@{ + Id = [string]$id + DisplayName = [string]$name + HasMembers = [bool]$hasMembers + } + } + } + # Alphabetisch sortieren + $items = @($items | Sort-Object -Property DisplayName -Culture de-DE) + $script:State.Groups = $items + return @{ items = $items; count = $items.Count } +} + +function Get-RpaGroupsEndpoint { + $err = Test-Connected + if ($err) { return $err } + + $rpaNames = @($script:Settings.rpa.groupNames | Where-Object { $_ }) + if ($rpaNames.Count -eq 0) { + # Settings leer -> ehrlich melden, das Frontend zeigt einen Konfig-Hinweis. + return @{ items = @(); count = 0; notConfigured = $true } + } + $items = @() + foreach ($n in $rpaNames) { + try { + # WICHTIG: @() wrappen — sonst entwickelt PowerShell ein Single-Item- + # Resultat zu einem Skalar und $g[0] indexiert in Properties statt Array. + $g = @(Get-GraphGroupByFilter -Filter "displayName eq '$n'" -Property @("id","displayName") -ExpandMembers) + if ($g.Count -gt 0) { + $first = $g[0] + # Defensiv beide Casings abfragen, da $select je nach Modul-Version unterschiedlich casing zurueckgibt + $name = if ($first.displayName) { $first.displayName } elseif ($first.displayname) { $first.displayname } else { $n } + $id = if ($first.id) { $first.id } elseif ($first.Id) { $first.Id } else { $null } + $members = if ($null -ne $first.members) { $first.members } else { $first.Members } + $hasMembers = ($members -is [array] -and $members.Count -gt 0) -or ($null -ne $members -and -not ($members -is [array])) + if ($id) { + $items += [pscustomobject]@{ Id = [string]$id; DisplayName = [string]$name; HasMembers = [bool]$hasMembers } + Write-Host " RPA: $name ($id) members=$hasMembers" -ForegroundColor DarkGray + } + } else { + Write-Host " RPA-Gruppe nicht gefunden: $n" -ForegroundColor Yellow + } + } catch { + Write-Host " RPA-Gruppe-Lookup-Fehler ($n): $($_.Exception.Message)" -ForegroundColor Yellow + } + } + $script:State.RpaGroups = $items + return @{ items = $items; count = $items.Count } +} + +function Test-GroupNameEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + $name = $Body.displayName + $existing = Get-GraphGroupByFilter -Filter "displayName eq '$name'" -Property @("id","displayName") + return @{ + exists = ($existing -and @($existing).Count -gt 0) + displayName = $name + } +} + +function New-GroupEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + + $appName = $Body.appName + $customName = $Body.customName # optional - falls null wird automatischer Name verwendet + + # Intent steuert Naming + Zuweisung. Default "required" fuer Backwards-Compat. + $intent = if ($Body.intent) { ([string]$Body.intent).ToLower() } else { "required" } + if ($intent -notin @("required","available")) { + return @{ __status = 400; error = "Intent muss 'required' oder 'available' sein" } + } + + $namingObj = if ($intent -eq "available") { $script:Settings.availableGroupNaming } else { $script:Settings.requiredGroupNaming } + $defaultSuffix = if ($intent -eq "available") { "-available" } else { "-required" } + $namingPrefix = if ($namingObj -and $namingObj.prefix) { $namingObj.prefix } else { "intune-win-app-" } + $namingSuffix = if ($namingObj -and $namingObj.suffix) { $namingObj.suffix } else { $defaultSuffix } + + $cleaned = if ($customName) { Format-GroupNameSlug -Name $customName } else { Format-GroupNameSlug -Name $appName } + $displayName = "$namingPrefix$cleaned$namingSuffix".ToLower() + $mailNickname = $displayName + + # check duplikat + $existing = Get-GraphGroupByFilter -Filter "displayName eq '$displayName'" -Property @("id","displayName") + if ($existing -and @($existing).Count -gt 0) { + return @{ __status = 409; error = "Gruppe existiert bereits"; displayName = $displayName } + } + + $group = New-GraphSecurityGroup -DisplayName $displayName -MailNickname $mailNickname + + $assigned = $false + if ($Body.assignToApp -eq $true -and $Body.appId) { + try { + Add-GraphAppAssignment -AppId $Body.appId -Intent $intent -GroupId $group.id + $assigned = $true + } catch { + Write-Host "Auto-Assign fehlgeschlagen: $_" -ForegroundColor Yellow + } + } + + return @{ + id = $group.id + displayName = $group.displayName + intent = $intent + assigned = $assigned + } +} + +function Format-GroupNameSlug { + param([string]$Name) + $clean = $Name -replace '[^a-zA-Z0-9-]', '-' + $clean = $clean -replace '-+', '-' + $clean = $clean.Trim('-') + return $clean.ToLower() +} + +function Get-GroupMembersEndpoint { + param([string]$GroupId) + $err = Test-Connected + if ($err) { return $err } + $members = Get-GraphGroupMembersTransitive -GroupId $GroupId + $items = @() + foreach ($m in $members) { + $items += [pscustomobject]@{ + Id = $m.id + DisplayName = $m.displayName + UserPrincipalName = $m.userPrincipalName + } + } + return @{ items = $items; count = $items.Count } +} + +# ============================================================ +# Users +# ============================================================ + +function Search-UsersEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + $term = $Query.q + if ([string]::IsNullOrWhiteSpace($term) -or $term.Length -lt 2) { + return @{ items = @(); count = 0 } + } + $raw = Search-GraphUser -SearchTerm $term + $items = @() + foreach ($u in $raw) { + $items += [pscustomobject]@{ + Id = $u.id + DisplayName = $u.displayName + UserPrincipalName = $u.userPrincipalName + Mail = $u.mail + Department = $u.department + } + } + return @{ items = $items; count = $items.Count } +} + +# ============================================================ +# Apps +# ============================================================ + +function Get-AppsEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + + if (-not $Query) { $Query = @{} } + $forceRefresh = ($Query.refresh -eq "true") + + if (-not $forceRefresh -and $script:State.Apps.Count -gt 0) { + return @{ items = $script:State.Apps; count = $script:State.Apps.Count; cached = $true } + } + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + Write-Host "Lade Apps aus Intune..." -ForegroundColor Cyan + $raw = Get-GraphMobileApps -WithAssignments + Write-Host " -> $($raw.Count) Apps gesamt vor Filter ($([int]$sw.Elapsed.TotalSeconds)s)" -ForegroundColor DarkGray + + # Eindeutige Gruppen-IDs aus allen Zuweisungen sammeln + $groupIds = @{} + foreach ($app in $raw) { + foreach ($a in @($app.assignments)) { + $tgt = $a.target + if ($tgt.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and $tgt.groupId) { + $groupIds[$tgt.groupId] = $true + } + } + } + Write-Host " -> $($groupIds.Count) eindeutige Gruppen referenziert" -ForegroundColor DarkGray + + # Lookup mit bereits bekannten Namen vorbefuellen + $lookup = @{} + foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName } + foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName } + + # Unbekannte IDs in Batches per directoryObjects/getByIds aufloesen (1 Request fuer 1000 IDs statt 1000 Requests) + $unknown = [string[]]@($groupIds.Keys | ForEach-Object { [string]$_ } | Where-Object { $_ -and -not $lookup.ContainsKey($_) }) + if ($unknown.Count -gt 0) { + Write-Host " -> Loese $($unknown.Count) Gruppen-Namen via getByIds auf..." -ForegroundColor DarkGray + $sw2 = [System.Diagnostics.Stopwatch]::StartNew() + Resolve-GroupNamesBulk -Ids $unknown -Lookup $lookup + $sw2.Stop() + Write-Host " -> Aufloesung in $([int]$sw2.Elapsed.TotalSeconds)s erledigt" -ForegroundColor DarkGray + } + + $items = @() + foreach ($app in $raw) { + if (-not (Test-AppTypeAllowed -Type $app.'@odata.type')) { continue } + $items += Format-AppForFrontend -RawApp $app -AllGroupsLookup $lookup + } + $sw.Stop() + Write-Host " -> $($items.Count) Apps nach Filter ($([int]$sw.Elapsed.TotalSeconds)s gesamt)" -ForegroundColor Green + + $script:State.Apps = $items + return @{ items = $items; count = $items.Count; cached = $false } +} + +function Get-AppCategoriesEndpoint { + # Liefert eine Map appId -> [KategorieNamen] fuer alle gecachten Apps. + # Wird vom Frontend NACH dem App-Laden im Hintergrund geholt (blockiert + # das App-Laden nicht). Throttle-sicher per $batch. + $err = Test-Connected + if ($err) { return $err } + $ids = @($script:State.Apps | ForEach-Object { [string]$_.AppId } | Where-Object { $_ }) + if ($ids.Count -eq 0) { return @{ map = @{}; count = 0 } } + $sw = [System.Diagnostics.Stopwatch]::StartNew() + Write-Host "[CATS] Lade Kategorien fuer $($ids.Count) Apps via batch..." -ForegroundColor DarkCyan + $map = Get-GraphMobileAppCategoriesBatch -AppIds $ids + $sw.Stop() + # Cache mitfuehren, damit Filter auch ohne erneuten Call konsistent ist + foreach ($a in $script:State.Apps) { + if ($map.ContainsKey($a.AppId)) { $a.Categories = @($map[$a.AppId]) } + } + Write-Host " -> Kategorien fuer $($map.Keys.Count) Apps in $([int]$sw.Elapsed.TotalSeconds)s" -ForegroundColor DarkGray + return @{ map = $map; count = $map.Keys.Count } +} + +function Get-AppDetailsEndpoint { + param([string]$AppId) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($AppId)) { + return @{ __status = 400; error = "AppId fehlt" } + } + + # Drei Calls (App-Details + InstallSummary + Relationships) in EINEM + # HTTP-Roundtrip via Graph $batch — server-seitig parallelisiert, + # spart 60-70% Latenz. + $batch = Get-GraphAppDetailsBatch -AppId $AppId + $app = $batch.App + if (-not $app) { + return @{ __status = 404; error = "App nicht gefunden (Batch-Response ohne App-Body)" } + } + + # Helper: Wert holen, leere Strings als $null normalisieren + $val = { + param($obj, $name) + if ($null -eq $obj) { return $null } + $v = $obj.$name + if ($null -eq $v) { return $null } + if ($v -is [string] -and [string]::IsNullOrWhiteSpace($v)) { return $null } + return $v + } + + $type = [string]$app.'@odata.type' + $shortType = $type -replace '^#microsoft\.graph\.', '' + + # Min-OS in lesbarem Format zusammenfassen + $minOs = $null + $mos = $app.minimumSupportedOperatingSystem + if ($mos) { + $flags = @() + foreach ($p in @('v8_0','v8_1','v10_0','v10_1607','v10_1703','v10_1709','v10_1803','v10_1809','v10_1903','v10_1909','v10_2004','v10_20H2','v10_21H1','v10_21H2','v10_22H2','v11_21H2','v11_22H2','v11_23H2')) { + if ($mos.$p -eq $true) { $flags += ($p -replace '^v','Win ') } + } + if ($flags.Count -gt 0) { $minOs = ($flags -join ', ') } + } + + # Architekturen + $arch = $null + if ($app.applicableArchitectures) { $arch = [string]$app.applicableArchitectures } + + # Detection-Rules in Kurzform + $detection = @() + if ($app.detectionRules) { + foreach ($r in @($app.detectionRules)) { + $rt = [string]$r.'@odata.type' -replace '^#microsoft\.graph\.win32LobApp', '' + $summary = switch -Wildcard ($rt) { + 'FileSystemDetection' { "Datei: $([string]$r.path)\$([string]$r.fileOrFolderName)" } + 'RegistryDetection' { "Registry: $([string]$r.keyPath) ($([string]$r.valueName))" } + 'MsiInformation' { "MSI: $([string]$r.productCode)" } + 'ProductCodeDetection' { "MSI-ProductCode: $([string]$r.productCode)" } + 'PowerShellScriptDetection' { "PowerShell-Skript" } + default { $rt } + } + $detection += $summary + } + } + + # MSI-Details + $msi = $null + if ($app.msiInformation) { + $msi = @{ + ProductCode = [string]$app.msiInformation.productCode + ProductVersion = [string]$app.msiInformation.productVersion + Publisher = [string]$app.msiInformation.publisher + PackageType = [string]$app.msiInformation.packageType + UpgradeCode = [string]$app.msiInformation.upgradeCode + RequiresReboot = [bool]$app.msiInformation.requiresReboot + } + } + + # Return-Codes + $returnCodes = @() + if ($app.returnCodes) { + foreach ($rc in @($app.returnCodes)) { + $returnCodes += @{ Code = [int]$rc.returnCode; Type = [string]$rc.type } + } + } + + return @{ + AppId = [string]$app.id + Type = $shortType + DisplayName = & $val $app 'displayName' + Publisher = & $val $app 'publisher' + Developer = & $val $app 'developer' + Owner = & $val $app 'owner' + Description = & $val $app 'description' + Notes = & $val $app 'notes' + DisplayVersion = & $val $app 'displayVersion' + Version = & $val $app 'version' + ProductVersion = & $val $app 'productVersion' + FileName = & $val $app 'fileName' + SetupFilePath = & $val $app 'setupFilePath' + InstallCommandLine = & $val $app 'installCommandLine' + UninstallCommandLine = & $val $app 'uninstallCommandLine' + CommandLine = & $val $app 'commandLine' + InformationUrl = & $val $app 'informationUrl' + PrivacyInformationUrl= & $val $app 'privacyInformationUrl' + InstallExperience = if ($app.installExperience) { [string]$app.installExperience.runAsAccount } else { $null } + Architectures = $arch + MinimumOS = $minOs + IsFeatured = [bool]$app.isFeatured + # Dates explizit als ISO 8601 zurueckgeben — ConvertTo-Json wuerde + # [DateTime]-Objekte je nach PS-Version unterschiedlich (und teils + # JS-untauglich) serialisieren. + CreatedDateTime = ConvertTo-IsoDate (& $val $app 'createdDateTime') + LastModifiedDateTime = ConvertTo-IsoDate (& $val $app 'lastModifiedDateTime') + Categories = (Get-AppCategoryNames -RawCategories $app.categories -AppId $AppId) + DetectionRules = $detection + ReturnCodes = $returnCodes + Msi = $msi + InstallSummary = Get-AppInstallSummaryNormalized -AppId $AppId -Raw $batch.InstallSummary + # WICHTIG: @() Wrap am Call-Site — PowerShell entpackt sonst ein + # Single-Element-Array zu einer einzelnen Hashtable, und ConvertTo-Json + # serialisiert sie als Objekt statt als Array. Frontend sieht dann + # d.Dependencies.length === undefined und ueberspringt das Rendern. + # Items kommt aus dem Batch — kein zweiter /relationships-Request. + Dependencies = @(Get-AppRelationshipsNormalized -AppId $AppId -OdataKind '#microsoft.graph.mobileAppDependency' -Items $batch.Relationships) + Supersedence = @(Get-AppRelationshipsNormalized -AppId $AppId -OdataKind '#microsoft.graph.mobileAppSupersedence' -Items $batch.Relationships) + } +} + +# Normalisiert das installSummary-Objekt von Graph in flache, JS-freundliche +# Properties. Bei API-Fehler / fehlenden Werten -> $null (Frontend zeigt die +# Sektion dann nicht). +function Get-AppInstallSummaryNormalized { + param( + [string]$AppId, + # Optional: bereits geladenes Raw-Objekt (z.B. aus $batch) — spart den + # zusaetzlichen HTTP-Roundtrip. + $Raw = $null + ) + if ($null -eq $Raw) { + $Raw = Get-GraphMobileAppInstallSummary -AppId $AppId + } + if (-not $Raw) { return $null } + $raw = $Raw + $int = { param($v) if ($null -eq $v) { 0 } else { [int]$v } } + return @{ + InstalledDeviceCount = & $int $raw.installedDeviceCount + FailedDeviceCount = & $int $raw.failedDeviceCount + NotInstalledDeviceCount = & $int $raw.notInstalledDeviceCount + NotApplicableDeviceCount = & $int $raw.notApplicableDeviceCount + PendingInstallDeviceCount = & $int $raw.pendingInstallDeviceCount + InstalledUserCount = & $int $raw.installedUserCount + FailedUserCount = & $int $raw.failedUserCount + NotInstalledUserCount = & $int $raw.notInstalledUserCount + NotApplicableUserCount = & $int $raw.notApplicableUserCount + PendingInstallUserCount = & $int $raw.pendingInstallUserCount + } +} + +# Filtert die Relationships nach @odata.type (Dependency oder Supersedence) +# und packt sie in eine flache, JS-freundliche Struktur. Bei fehlenden +# Properties (Microsoft liefert nicht immer Display-Name fuer Targets!) +# wird die App-ID als Fallback verwendet. +function Get-AppRelationshipsNormalized { + param( + [string]$AppId, + [string]$OdataKind, + # Optional: vorhandene Items (z.B. aus $batch). Wenn gesetzt wird + # KEIN zusaetzlicher /relationships-Request mehr gemacht. + $Items = $null + ) + if ($null -eq $Items) { + $items = @() + try { + $items = Get-GraphMobileAppRelationships -AppId $AppId + } catch { return @() } + } else { + $items = $Items + } + if (-not $items) { return @() } + + # Normalisierung: Casing + fuehrendes # entfernen, um Mikro-Unterschiede + # in der API-Response zuverlaessig zu matchen. + $norm = { param($t) ([string]$t).TrimStart('#').ToLower() } + $wanted = & $norm $OdataKind + $isDependency = $wanted -like '*dependency*' + $isSupersedence= $wanted -like '*supersedence*' + + $result = @() + foreach ($r in $items) { + $actual = & $norm $r.'@odata.type' + if ($actual -ne $wanted) { continue } + $entry = @{ + Id = [string]$r.id + TargetId = [string]$r.targetId + TargetDisplayName = if ($r.targetDisplayName) { [string]$r.targetDisplayName } else { [string]$r.targetId } + TargetPublisher = if ($r.targetPublisher) { [string]$r.targetPublisher } else { $null } + TargetDisplayVersion = if ($r.targetDisplayVersion) { [string]$r.targetDisplayVersion } else { $null } + TargetType = if ($r.targetType) { [string]$r.targetType } else { $null } + } + if ($isDependency) { + $entry['DependencyType'] = if ($r.dependencyType) { [string]$r.dependencyType } else { 'detect' } + } elseif ($isSupersedence) { + $entry['SupersedenceType'] = if ($r.supersedenceType) { [string]$r.supersedenceType } else { 'update' } + } + $result += $entry + } + Write-Host " [RELS] $AppId -> $($result.Count) gefiltert auf '$wanted'" -ForegroundColor DarkGray + return $result +} + +# ============================================================ +# Membership Check +# ============================================================ + +function Get-MembershipBulkEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + + $targetIds = @($Body.targets | ForEach-Object { [string]$_ } | Where-Object { $_ }) + $groupIds = @($Body.groupIds | ForEach-Object { [string]$_ } | Where-Object { $_ }) + if ($targetIds.Count -eq 0 -or $groupIds.Count -eq 0) { + return @{ memberships = @{} } + } + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + Write-Host " Bulk-Membership: $($targetIds.Count) Targets x $($groupIds.Count) Gruppen" -ForegroundColor DarkGray + + # Pro Target: ALLE Gruppen-Mitgliedschaften EINMAL holen (transitiveMemberOf) + # Statt pro Gruppe pro Target eine Anfrage. Bei 1 Target + 600 Gruppen + # = 1 Call statt 600. + $perTarget = @{} + foreach ($tid in $targetIds) { + $set = New-Object System.Collections.Generic.HashSet[string] + try { + $uri = "https://graph.microsoft.com/v1.0/directoryObjects/$tid/transitiveMemberOf?`$select=id&`$top=999" + $next = $uri + do { + $resp = Invoke-MgGraphRequest -Uri $next -Method GET + if ($resp.value) { + foreach ($g in $resp.value) { + if ($g.id) { [void]$set.Add([string]$g.id) } + } + } + $next = $resp.'@odata.nextLink' + } while ($next) + } catch { + Write-Host " -> transitiveMemberOf fehlgeschlagen fuer $tid : $($_.Exception.Message)" -ForegroundColor DarkYellow + } + $perTarget[$tid] = $set + } + + # Lokal mappen — Group-IDs gegen alle Target-Sets pruefen + $result = @{} + foreach ($gid in $groupIds) { + if ($gid -in @("ALL_USERS","ALL_DEVICES")) { + $result[$gid] = @{ status = "Native"; matched = 0; total = $targetIds.Count } + continue + } + $matched = 0 + foreach ($tid in $targetIds) { + if ($perTarget[$tid].Contains($gid)) { $matched++ } + } + $status = if ($matched -eq 0) { "None" } + elseif ($matched -eq $targetIds.Count) { "Full" } + else { "Partial" } + $result[$gid] = @{ status = $status; matched = $matched; total = $targetIds.Count } + } + + $sw.Stop() + Write-Host " Bulk-Membership: $($result.Count) Resultate in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray + return @{ memberships = $result } +} + +function Get-MembershipEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + + $targetIds = @($Query.targets -split ",") + $groupId = $Query.groupId + + if ([string]::IsNullOrWhiteSpace($groupId) -or $targetIds.Count -eq 0) { + return @{ status = "None"; details = @() } + } + + if ($groupId -in @("ALL_USERS","ALL_DEVICES")) { + return @{ status = "Native"; details = @() } + } + + if (-not $script:State.GroupMembers.ContainsKey($groupId)) { + try { + $members = Get-GraphGroupMembersTransitive -GroupId $groupId + $ids = New-Object System.Collections.Generic.HashSet[string] + foreach ($m in $members) { [void]$ids.Add($m.id) } + $script:State.GroupMembers[$groupId] = $ids + } catch { + return @{ status = "Unknown"; error = $_.Exception.Message } + } + } + + $set = $script:State.GroupMembers[$groupId] + $matchCount = 0 + $details = @() + foreach ($tid in $targetIds) { + $isMember = $set.Contains($tid) + if ($isMember) { $matchCount++ } + $details += @{ id = $tid; isMember = $isMember } + } + $status = if ($matchCount -eq 0) { "None" } + elseif ($matchCount -eq $targetIds.Count) { "Full" } + else { "Partial" } + return @{ + status = $status + matched = $matchCount + total = $targetIds.Count + details = $details + } +} + +# ============================================================ +# App-Loeschung & Assignment-Entfernung +# ============================================================ + +function Remove-AppEndpoint { + param([string]$AppId) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($AppId)) { + return @{ __status = 400; error = "AppId fehlt" } + } + + # App-Namen + Source fuer Logging aus Cache versuchen (nice-to-have) + $appName = $AppId + $appSource = $null + $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 + if ($cachedApp) { + $appName = $cachedApp.AppName + $appSource = $cachedApp.Source + } + + # Vendor-managed Apps (PMPC, Robopack, ...) koennen nur im jeweiligen + # Vendor-Portal geloescht werden. Ein Delete in Intune liefe auf einen + # verwirrenden 400er hinaus oder der Vendor wuerde die App beim naechsten + # Sync wieder anlegen. Wir blocken den Versuch hier. + $vendor = Find-VendorBySource -Source $appSource + if ($vendor -and $vendor.block -and $vendor.block.delete) { + Write-Host "[DELETE APP] BLOCK $appName ($($vendor.displayName)-managed)" -ForegroundColor DarkYellow + return @{ + __status = 409 + error = "Diese App wird durch $($vendor.displayName) verwaltet und kann nur im $($vendor.displayName)-Portal geloescht werden — nicht in Intune." + code = "$($vendor.id)Managed" + source = $vendor.displayName + } + } + + Write-Host "[DELETE APP] $appName ($AppId)" -ForegroundColor Yellow + try { + Remove-GraphMobileApp -AppId $AppId + } catch { + $exMsg = $_.Exception.Message + # Original-Graph-Body extrahieren (am informativsten) + $graphBody = $null + try { $graphBody = $_.ErrorDetails.Message } catch {} + if (-not $graphBody -and $exMsg -match 'Graph-Response:\s*(.+)$') { + $graphBody = $matches[1] + } + $graphMsg = $null + if ($graphBody) { + try { + $j = $graphBody | ConvertFrom-Json + if ($j.error -and $j.error.message) { $graphMsg = [string]$j.error.message } + } catch {} + } + + # Bei 400: Relationships pruefen — haeufige Ursache + $rels = @() + if ($exMsg -match '400|BadRequest') { + try { $rels = Get-GraphMobileAppRelationships -AppId $AppId } catch {} + } + + $details = Get-GraphErrorFriendly -ErrorRecord $_ + $friendly = if ($graphMsg) { $graphMsg } else { $details.Friendly } + $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } + + # Wenn Relationships gefunden: Hinweis daran haengen + if ($rels.Count -gt 0) { + $relTypes = @($rels | ForEach-Object { ([string]$_.'@odata.type' -replace '^#microsoft\.graph\.','') } | Select-Object -Unique) + $friendly = "$friendly`n`nDie App hat $($rels.Count) Abhaengigkeit(en) ($($relTypes -join ', ')). Bitte zuerst diese Beziehungen im Intune-Portal entfernen (Eigenschaften > Abhaengigkeiten / Supersedence)." + } + + Write-Host "[DELETE APP] FAIL [$($details.Code)] $friendly" -ForegroundColor Red + if ($graphBody) { Write-Host " Graph-Body: $graphBody" -ForegroundColor DarkRed } + + return @{ + __status = $status + error = $friendly + code = $details.Code + details = $details.Full + graph = $graphBody + relationships = $rels.Count + } + } + + # Cache aktualisieren: geloeschte App rauswerfen + if ($script:State.Apps -and $script:State.Apps.Count -gt 0) { + $script:State.Apps = @($script:State.Apps | Where-Object { $_.AppId -ne $AppId }) + } + Write-Host "[DELETE APP] OK $appName" -ForegroundColor Green + return @{ ok = $true; appId = $AppId; appName = $appName } +} + +function Update-AppEndpoint { + param( + [string]$AppId, + $Body + ) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($AppId)) { + return @{ __status = 400; error = "AppId fehlt" } + } + if (-not $Body) { + return @{ __status = 400; error = "Request-Body fehlt" } + } + + # Aktuell wird nur displayName per UI editiert. Andere Felder waeren leicht + # nachruestbar, brauchen aber jeweils eigene Validierung. + $newName = $null + if ($Body.displayName) { $newName = [string]$Body.displayName } + if (-not $newName) { + return @{ __status = 400; error = "displayName fehlt im Body" } + } + $newName = $newName.Trim() + if ($newName.Length -lt 1) { return @{ __status = 400; error = "Name darf nicht leer sein" } } + if ($newName.Length -gt 256) { return @{ __status = 400; error = "Name zu lang (max. 256 Zeichen)" } } + + # App im Cache nachschlagen — fuer Source-Check (PMPC blocken) und Typ (PATCH braucht @odata.type) + $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 + if (-not $cachedApp) { + return @{ __status = 404; error = "App im Cache nicht gefunden. Bitte Apps neu laden und erneut versuchen." } + } + + $vendor = Find-VendorBySource -Source $cachedApp.Source + if ($vendor -and $vendor.block -and $vendor.block.rename) { + Write-Host "[PATCH APP] BLOCK $($cachedApp.AppName) ($($vendor.displayName)-managed)" -ForegroundColor DarkYellow + return @{ + __status = 409 + error = "Diese App wird durch $($vendor.displayName) verwaltet — Namensaenderungen in Intune werden beim naechsten Sync ueberschrieben. Bitte im $($vendor.displayName)-Portal umbenennen." + code = "$($vendor.id)Managed" + source = $vendor.displayName + } + } + + if (-not $cachedApp.AppTypeRaw) { + return @{ __status = 500; error = "App-Typ unbekannt — Cache ist inkonsistent. Bitte Apps neu laden." } + } + + $oldName = [string]$cachedApp.AppName + if ($oldName -eq $newName) { + return @{ ok = $true; appId = $AppId; appName = $newName; unchanged = $true } + } + + Write-Host "[PATCH APP] '$oldName' -> '$newName' ($AppId)" -ForegroundColor Yellow + try { + Update-GraphMobileApp -AppId $AppId -AppTypeRaw $cachedApp.AppTypeRaw -Patch @{ displayName = $newName } + } catch { + $exMsg = $_.Exception.Message + $graphBody = $null + try { $graphBody = $_.ErrorDetails.Message } catch {} + if (-not $graphBody -and $exMsg -match 'Graph-Response:\s*(.+)$') { $graphBody = $matches[1] } + $graphMsg = $null + if ($graphBody) { + try { + $j = $graphBody | ConvertFrom-Json + if ($j.error -and $j.error.message) { $graphMsg = [string]$j.error.message } + } catch {} + } + $details = Get-GraphErrorFriendly -ErrorRecord $_ + $friendly = if ($graphMsg) { $graphMsg } else { $details.Friendly } + $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } + Write-Host "[PATCH APP] FAIL [$($details.Code)] $friendly" -ForegroundColor Red + if ($graphBody) { Write-Host " Graph-Body: $graphBody" -ForegroundColor DarkRed } + return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } + } + + # Cache aktualisieren + $cachedApp.AppName = $newName + Write-Host "[PATCH APP] OK '$newName'" -ForegroundColor Green + return @{ ok = $true; appId = $AppId; appName = $newName; previousName = $oldName } +} + +# Oeffnet einen nativen Windows-Datei-Dialog auf dem Server-Rechner (= der +# Rechner des Users, da localhost-Tool). Browser geben den vollen lokalen Pfad +# nie heraus — deshalb der Backend-Dialog. Laeuft in einem STA-Runspace, weil +# WinForms-Dialoge zwingend STA brauchen. Owner-Form mit TopMost holt den Dialog +# vor das Browser-Fenster. +function Show-OpenFileDialog { + param( + [string]$Filter = "Alle Dateien (*.*)|*.*", + [string]$Title = "Datei auswaehlen" + ) + # Eigener powershell.exe -STA Prozess: in einem In-Process-Runspace blitzt + # der Dialog nur kurz auf (keine echte Windows-Message-Pump, er bleibt nicht + # modal). Ein separater STA-Konsolen-Prozess hat einen vollwertigen + # STA-Hauptthread -> der Dialog erscheint und bleibt offen bis zur Auswahl. + # Ergebnis-Pfad kommt ueber stdout zurueck. + $fEsc = $Filter -replace "'", "''" + $tEsc = $Title -replace "'", "''" + $inner = @" +`$ProgressPreference = 'SilentlyContinue' +Add-Type -AssemblyName System.Windows.Forms +`$dlg = New-Object System.Windows.Forms.OpenFileDialog +`$dlg.Filter = '$fEsc' +`$dlg.Title = '$tEsc' +`$dlg.CheckFileExists = `$true +`$dlg.Multiselect = `$false +`$owner = New-Object System.Windows.Forms.Form +`$owner.TopMost = `$true +`$owner.ShowInTaskbar = `$false +`$owner.WindowState = 'Minimized' +`$owner.Show(); `$owner.Activate() +`$r = `$dlg.ShowDialog(`$owner) +`$owner.Dispose() +if (`$r -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(`$dlg.FileName) } +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner)) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = "powershell.exe" + $psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -EncodedCommand $encoded" + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.CreateNoWindow = $true + try { + $proc = [System.Diagnostics.Process]::Start($psi) + $path = $proc.StandardOutput.ReadToEnd() + $proc.WaitForExit() + } catch { + throw "Datei-Dialog-Prozess konnte nicht gestartet werden: $($_.Exception.Message)" + } + return ([string]$path).Trim() +} + +function Invoke-FilePickerEndpoint { + # Oeffnet den Datei-Dialog und liefert den gewaehlten Pfad. Braucht keine + # Graph-Verbindung. Body optional: { filter, title }. + param($Body) + $filter = "Intune-Pakete (*.intunewin)|*.intunewin|MSI (*.msi)|*.msi|MSIX/AppX (*.msix;*.appx)|*.msix;*.appx|Alle Dateien (*.*)|*.*" + $title = "Setup-Datei auswaehlen" + if ($Body -and $Body.filter) { $filter = [string]$Body.filter } + if ($Body -and $Body.title) { $title = [string]$Body.title } + try { + $path = Show-OpenFileDialog -Filter $filter -Title $title + } catch { + return @{ __status = 500; error = "Datei-Dialog fehlgeschlagen: $($_.Exception.Message)" } + } + if (-not $path) { return @{ ok = $true; cancelled = $true } } + return @{ ok = $true; path = $path } +} + +# App-Typ (@odata.type) -> unterstuetzter Content-Update-Pfad + erlaubte Endung. +# Phase 1: nur win32LobApp/.intunewin aktiv; MSI/MSIX kommen in Phase 2/3. +function Get-AppContentTypeMap { + param([string]$AppTypeRaw) + $t = ($AppTypeRaw -replace '^#microsoft\.graph\.', '') + switch ($t) { + 'win32LobApp' { return @{ graphType = 'win32LobApp'; exts = @('.intunewin'); phase = 1 } } + 'windowsMobileMSI' { return @{ graphType = 'windowsMobileMSI'; exts = @('.msi'); phase = 2 } } + 'windowsUniversalAppX'{ return @{ graphType = 'windowsUniversalAppX'; exts = @('.msix','.appx'); phase = 3 } } + default { return $null } + } +} + +function Update-AppContentEndpoint { + # Laedt eine neue Setup-Datei (lokaler Pfad) in eine native App und aktiviert + # sie als neue contentVersion. Body: { filePath, displayVersion? }. + param([string]$AppId, $Body) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($AppId)) { return @{ __status = 400; error = "AppId fehlt" } } + if (-not $Body) { return @{ __status = 400; error = "Request-Body fehlt" } } + + $filePath = [string]$Body.filePath + $displayVersion = if ($Body.displayVersion) { [string]$Body.displayVersion } else { $null } + if (-not $filePath) { return @{ __status = 400; error = "filePath fehlt im Body" } } + + # App aus Cache (fuer Typ + Vendor-Check) + $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 + if (-not $cachedApp) { return @{ __status = 404; error = "App im Cache nicht gefunden. Bitte Apps neu laden." } } + if (-not $cachedApp.AppTypeRaw) { return @{ __status = 500; error = "App-Typ unbekannt — Cache inkonsistent, bitte Apps neu laden." } } + + # Vendor-managed Apps blocken (PMPC/Robopack) — analog Rename/Delete + $vendor = Find-VendorBySource -Source $cachedApp.Source + if ($vendor) { + return @{ + __status = 409 + error = "Diese App wird durch $($vendor.displayName) verwaltet — der Content kann nur im $($vendor.displayName)-Portal aktualisiert werden." + code = "$($vendor.id)Managed" + source = $vendor.displayName + } + } + + # App-Typ unterstuetzt? + $map = Get-AppContentTypeMap -AppTypeRaw $cachedApp.AppTypeRaw + if (-not $map) { + return @{ __status = 400; error = "App-Typ '$($cachedApp.AppTypeRaw)' unterstuetzt keine Content-Aktualisierung." } + } + if ($map.phase -gt 1) { + return @{ __status = 501; error = "Content-Update fuer $($map.graphType) ist noch nicht aktiviert (kommt in einer spaeteren Phase). Aktuell nur .intunewin (win32LobApp)." } + } + + # Datei + Endung pruefen + if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { + return @{ __status = 400; error = "Datei nicht gefunden: $filePath" } + } + $ext = [IO.Path]::GetExtension($filePath).ToLower() + if ($ext -notin $map.exts) { + return @{ __status = 400; error = "Dateiendung '$ext' passt nicht zum App-Typ $($map.graphType). Erwartet: $($map.exts -join ', ')" } + } + + Write-Host "[CONTENT] Update $($cachedApp.AppName) ($($map.graphType)) <- $filePath" -ForegroundColor Yellow + try { + $result = Update-GraphAppContent -AppId $AppId -GraphTypeRaw $cachedApp.AppTypeRaw -FilePath $filePath -DisplayVersion $displayVersion + } catch { + $exMsg = $_.Exception.Message + $graphBody = $null + try { $graphBody = $_.ErrorDetails.Message } catch {} + if (-not $graphBody -and $exMsg -match 'Graph-Response:\s*(.+)$') { $graphBody = $matches[1] } + $details = Get-GraphErrorFriendly -ErrorRecord $_ + $friendly = if ($exMsg) { $exMsg } else { $details.Friendly } + $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } + Write-Host "[CONTENT] FAIL [$($details.Code)] $friendly" -ForegroundColor Red + if ($graphBody) { Write-Host " Graph-Body: $graphBody" -ForegroundColor DarkRed } + return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } + } + + # App-Liste-Cache invalidieren, damit neue Version/Daten nachgeladen werden + if ($displayVersion) { $cachedApp.Version = $displayVersion } + $script:State.Apps = @() + Write-Host "[CONTENT] OK $($cachedApp.AppName) -> contentVersion $($result.contentVersion)" -ForegroundColor Green + return @{ + ok = $true + appId = $AppId + appName = $cachedApp.AppName + contentVersion = $result.contentVersion + displayVersion = $displayVersion + } +} + +function Add-AppAssignmentEndpoint { + # Erzeugt eine neue Zuweisung fuer eine App. + # Body: + # { intent: "available"|"required", + # target: "ALL_USERS"|"ALL_DEVICES"|"" } + param( + [string]$AppId, + $Body + ) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($AppId)) { + return @{ __status = 400; error = "AppId fehlt" } + } + if (-not $Body) { + return @{ __status = 400; error = "Request-Body fehlt" } + } + + $intent = if ($Body.intent) { ([string]$Body.intent).ToLower() } else { "" } + if ($intent -notin @("required","available")) { + return @{ __status = 400; error = "intent muss 'required' oder 'available' sein" } + } + $target = [string]$Body.target + if ([string]::IsNullOrWhiteSpace($target)) { + return @{ __status = 400; error = "target fehlt (ALL_USERS / ALL_DEVICES / )" } + } + # Bei Group-Target: zumindest grobe Hex/GUID-Form pruefen damit wir keine + # Garbage an Graph schicken. + if ($target -notin @("ALL_USERS","ALL_DEVICES") -and $target -notmatch '^[0-9a-fA-F-]{8,}$') { + return @{ __status = 400; error = "Ungueltige target-GUID: $target" } + } + + # App-Cache fuer logging + $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 + $appName = if ($cachedApp) { $cachedApp.AppName } else { $AppId } + + Write-Host "[ADD ASSIGN] $appName -> intent=$intent target=$target" -ForegroundColor Yellow + try { + Add-GraphAppAssignment -AppId $AppId -Intent $intent -GroupId $target + } catch { + $graphBody = $null + try { $graphBody = $_.ErrorDetails.Message } catch {} + $graphMsg = $null + if ($graphBody) { + try { $j = $graphBody | ConvertFrom-Json; if ($j.error -and $j.error.message) { $graphMsg = [string]$j.error.message } } catch {} + } + $details = Get-GraphErrorFriendly -ErrorRecord $_ + $friendly = if ($graphMsg) { $graphMsg } else { $details.Friendly } + $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*409*') { 409 } else { 500 } + Write-Host "[ADD ASSIGN] FAIL [$($details.Code)] $friendly" -ForegroundColor Red + return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } + } + + # Cache aktualisieren — neue Zuweisung im App-Eintrag ergaenzen + if ($cachedApp) { + $entry = if ($target -eq "ALL_USERS") { + @{ GroupId = "ALL_USERS"; GroupName = "All Users"; IsNative = $true } + } elseif ($target -eq "ALL_DEVICES") { + @{ GroupId = "ALL_DEVICES"; GroupName = "All Devices"; IsNative = $true } + } else { + # Group-Namen aus den geladenen Gruppen oder Graph nachschlagen + $groupName = $target + $lookup = @{} + foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName } + foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName } + if ($lookup.ContainsKey($target)) { $groupName = $lookup[$target] } + else { + try { + $g = Get-GraphGroupById -Id $target -Property @("id","displayName") + if ($g.displayName) { $groupName = [string]$g.displayName } + } catch {} + } + @{ GroupId = $target; GroupName = $groupName; IsNative = $false } + } + if ($intent -eq "available") { + $cachedApp.AvailableGroups = @($cachedApp.AvailableGroups + $entry) + $cachedApp.AvailableCount = $cachedApp.AvailableGroups.Count + } else { + $cachedApp.RequiredGroups = @($cachedApp.RequiredGroups + $entry) + $cachedApp.RequiredCount = $cachedApp.RequiredGroups.Count + } + } + + Write-Host "[ADD ASSIGN] OK" -ForegroundColor Green + return @{ ok = $true; appId = $AppId; intent = $intent; target = $target } +} + +function Remove-AppAssignmentEndpoint { + param( + [string]$AppId, + [string]$GroupId, + $Query + ) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($AppId) -or [string]::IsNullOrWhiteSpace($GroupId)) { + return @{ __status = 400; error = "AppId und GroupId erforderlich" } + } + + $intent = $null + if ($Query -and $Query.type) { + $t = ([string]$Query.type).ToLower() + if ($t -in @('available','required')) { $intent = $t } + } + + Write-Host "[DELETE ASSIGN] App=$AppId Group=$GroupId Intent=$(if ($intent) { $intent } else { '(alle)' })" -ForegroundColor Yellow + try { + $deleted = Remove-GraphAppAssignmentByGroup -AppId $AppId -GroupId $GroupId -Intent $intent + } catch { + $details = Get-GraphErrorFriendly -ErrorRecord $_ + Write-Host "[DELETE ASSIGN] FAIL [$($details.Code)] $($details.Friendly)" -ForegroundColor Red + $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } + return @{ __status = $status; error = $details.Friendly; code = $details.Code } + } + + if (-not $deleted -or $deleted.Count -eq 0) { + Write-Host "[DELETE ASSIGN] Keine passende Zuweisung gefunden" -ForegroundColor DarkYellow + return @{ __status = 404; error = "Keine passende Zuweisung gefunden" } + } + + # Cache aktualisieren: zuweisungs-Eintrag aus der App entfernen + if ($script:State.Apps -and $script:State.Apps.Count -gt 0) { + foreach ($app in $script:State.Apps) { + if ($app.AppId -ne $AppId) { continue } + if ($intent -in @($null,'available') -and $app.AvailableGroups) { + $app.AvailableGroups = @($app.AvailableGroups | Where-Object { $_.GroupId -ne $GroupId }) + $app.AvailableCount = $app.AvailableGroups.Count + } + if ($intent -in @($null,'required') -and $app.RequiredGroups) { + $app.RequiredGroups = @($app.RequiredGroups | Where-Object { $_.GroupId -ne $GroupId }) + $app.RequiredCount = $app.RequiredGroups.Count + } + } + } + Write-Host "[DELETE ASSIGN] OK ($($deleted.Count) entfernt)" -ForegroundColor Green + return @{ ok = $true; appId = $AppId; groupId = $GroupId; removed = $deleted.Count } +} + +# ============================================================ +# Apply Assignments +# ============================================================ + +function Invoke-ApplyEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + + # Pre-Flight: Token-Check + $ctx = $null + try { $ctx = Get-MgContext } catch {} + if (-not $ctx -or -not $ctx.Account) { + Write-Host "[APPLY] Get-MgContext leer — Token verloren!" -ForegroundColor Red + $script:State.Connected = $false + return @{ __status = 401; error = "Microsoft-Graph-Token verloren. Bitte neu anmelden." } + } + Write-Host "[APPLY] Pre-Flight OK — Account=$($ctx.Account)" -ForegroundColor DarkGray + + # Bevorzugt: flache Ops-Liste mit pro-Item-Empfaenger. + # Backwards-compat: alte targets/assignments-Struktur wird zur Ops-Liste expandiert. + $ops = @() + if ($Body.ops) { + $ops = @($Body.ops) + } elseif ($Body.targets -and $Body.assignments) { + foreach ($t in @($Body.targets)) { + foreach ($a in @($Body.assignments)) { + $ops += @{ + targetId = $t.id + targetName = $t.displayName + targetType = $t.type + appId = $a.appId + appName = $a.appName + groupId = $a.groupId + groupName = $a.groupName + type = $a.type + } + } + } + } + + if ($ops.Count -eq 0) { return @{ __status = 400; error = "Keine Vorgaenge angegeben" } } + + $isUserMode = ($ops | Where-Object { $_.targetType -eq 'user' }).Count -gt 0 + $total = $ops.Count + $success = 0; $errors = 0; $skipped = 0 + $log = @() + $detailedResults = @() + + Write-Host "[APPLY] Body geparst: ops.Count=$($ops.Count) (deptOps=$(@($ops | Where-Object { $_.targetType -ne 'user' }).Count) userOps=$(@($ops | Where-Object { $_.targetType -eq 'user' }).Count))" -ForegroundColor DarkGray + Write-Host "[APPLY] Starte $total Vorgaenge..." -ForegroundColor Cyan + $applyStart = Get-Date + + foreach ($op in $ops) { + $tId = [string]$op.targetId + $tName = [string]$op.targetName + $aGid = [string]$op.groupId + $aGname = [string]$op.groupName + $aApp = [string]$op.appName + $aType = [string]$op.type + + $entry = "$tName -> $aApp ($aGname - $aType)" + if ($aGid -in @("ALL_USERS","ALL_DEVICES")) { + $skipped++ + $log += "[SKIP] $entry (Native Target)" + $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="Skipped"; message="Native target - nicht aenderbar" } + Write-Host " [SKIP] $entry" -ForegroundColor DarkYellow + continue + } + $opStart = Get-Date + try { + Write-Host " -> Add-GraphMember GroupId=$aGid DirectoryObjectId=$tId" -ForegroundColor DarkGray + Add-GraphMember -GroupId $aGid -DirectoryObjectId $tId + $opMs = [int]((Get-Date) - $opStart).TotalMilliseconds + $success++ + $log += "[OK] $entry" + $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="Success"; message="Hinzugefuegt" } + Write-Host " [OK] $entry (${opMs}ms)" -ForegroundColor Green + } catch { + $opMs = [int]((Get-Date) - $opStart).TotalMilliseconds + $details = Get-GraphErrorFriendly -ErrorRecord $_ + if ($details.IsWarning) { + $success++ + $log += "[OK*] $entry (bereits Mitglied)" + $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="AlreadyMember"; message=$details.Friendly } + Write-Host " [OK*] $entry (bereits Mitglied, ${opMs}ms)" -ForegroundColor DarkGreen + } else { + $errors++ + $log += "[FAIL] $entry [$($details.Code)] $($details.Friendly)" + $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="Error"; code=$details.Code; message=$details.Friendly } + Write-Host " [FAIL] $entry [$($details.Code)] $($details.Friendly) (${opMs}ms)" -ForegroundColor Red + Write-Host " Vollstaendig: $($details.Full)" -ForegroundColor DarkRed + } + } + } + + # Targets/Assignments fuer den HTML-Report rekonstruieren + $targets = @{} + $assignments = @{} + foreach ($op in $ops) { + $targets[$op.targetId] = @{ id=$op.targetId; displayName=$op.targetName; type=$op.targetType } + $aKey = "$($op.appId)|$($op.groupId)|$($op.type)" + $assignments[$aKey] = @{ appId=$op.appId; appName=$op.appName; groupId=$op.groupId; groupName=$op.groupName; type=$op.type } + } + $targets = @($targets.Values) + $assignments = @($assignments.Values) + + $applyMs = [int]((Get-Date) - $applyStart).TotalMilliseconds + Write-Host "[APPLY] Fertig in ${applyMs}ms — OK=$success Skip=$skipped Err=$errors" -ForegroundColor Cyan + + # Report VOR Response generieren + $reportFile = $null + try { + $reportFile = New-HtmlReport -Targets $targets -Assignments $assignments -IsUserMode $isUserMode -Success $success -Errors $errors -Skipped $skipped -Total $total -Log $log -Details $detailedResults + Write-Host "[APPLY] HTML-Report: $reportFile" -ForegroundColor DarkGray + } catch { + Write-Host "[APPLY] HTML-Report-Erstellung fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Red + } + + # Cache invalidieren - Membership stimmt nicht mehr + $script:State.GroupMembers = @{} + + $reportUrl = if ($reportFile) { "/reports/" + (Split-Path $reportFile -Leaf) } else { "" } + $resp = @{ + success = [int]$success + errors = [int]$errors + skipped = [int]$skipped + total = [int]$total + reportUrl = [string]$reportUrl + details = $detailedResults + build = [string]$script:BuildStamp + } + Write-Host "[APPLY] === Response: success=$success errors=$errors skipped=$skipped total=$total reportUrl='$reportUrl' details=$($detailedResults.Count) build=$script:BuildStamp" -ForegroundColor Yellow + return $resp +} + +function New-HtmlReport { + param($Targets, $Assignments, $IsUserMode, $Success, $Errors, $Skipped, $Total, $Log, $Details) + + Add-Type -AssemblyName System.Web + function _enc { param($s) if ($null -eq $s) { return "" } return [System.Web.HttpUtility]::HtmlEncode([string]$s) } + + $ts = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" + $tsHuman = Get-Date -Format "dd.MM.yyyy HH:mm:ss" + $name = "report-$ts.html" + $path = Join-Path $script:Config.ReportDir $name + $user = $env:USERNAME + $machine = $env:COMPUTERNAME + $tenant = if ($script:State.TenantId) { $script:State.TenantId } else { "" } + $account = if ($script:State.Account) { $script:State.Account } else { "" } + + # Erfolgsquote — Skipped zaehlen wir neutral, nicht als Fehler + $effective = [Math]::Max(1, ($Success + $Errors)) + $rate = [int](($Success / $effective) * 100) + $rateStatus = if ($Errors -eq 0 -and $Success -gt 0) { "ok" } elseif ($Errors -gt 0 -and $Success -gt 0) { "warn" } elseif ($Errors -gt 0) { "err" } else { "muted" } + + # Empfaenger-Mix + $targetGroups = @($Targets | Where-Object { $_.type -ne 'user' }) + $targetUsers = @($Targets | Where-Object { $_.type -eq 'user' }) + $grpSuffix = if ($targetGroups.Count -eq 1) { "" } else { "n" } + $assignSuffix = if ($Assignments.Count -eq 1) { "" } else { "en" } + + # Status-Mapping fuer Detail-Zeilen + $statusMeta = @{ + "Success" = @{ cls = "ok"; icon = "✓"; label = "Erfolgreich" } + "AlreadyMember" = @{ cls = "ok"; icon = "✓"; label = "Bereits Mitglied" } + "Skipped" = @{ cls = "skip"; icon = "–"; label = "Uebersprungen" } + "Error" = @{ cls = "err"; icon = "!"; label = "Fehler" } + } + + # Detail-Zeilen pro App gruppieren — ergibt eine kompaktere Darstellung + $byApp = @{} + foreach ($d in $Details) { + $key = [string]$d.app + if (-not $byApp.ContainsKey($key)) { $byApp[$key] = @() } + $byApp[$key] += $d + } + + $appBlocksHtml = "" + foreach ($appName in ($byApp.Keys | Sort-Object)) { + $rows = @($byApp[$appName]) + $okCount = @($rows | Where-Object { $_.status -in @('Success','AlreadyMember') }).Count + $skipCount = @($rows | Where-Object { $_.status -eq 'Skipped' }).Count + $errCount = @($rows | Where-Object { $_.status -eq 'Error' }).Count + + # Pro App: Gruppen-Spalte + Empfaenger-Zeilen + $rowsHtml = "" + foreach ($d in $rows) { + $st = $statusMeta[[string]$d.status] + if (-not $st) { $st = @{ cls = ""; icon = ""; label = [string]$d.status } } + $msg = if ($d.message) { _enc $d.message } else { "" } + $errCode = if ($d.code) { " $(_enc $d.code)" } else { "" } + $rowsHtml += "" + + "$($st.icon) $($st.label)" + + "$(_enc $d.target)" + + "$(_enc $d.group)" + + "$(_enc $d.type)" + + "$msg$errCode" + + "" + } + $appHeadStats = "" + if ($okCount -gt 0) { $appHeadStats += "$okCount OK" } + if ($skipCount -gt 0) { $appHeadStats += "$skipCount Skip" } + if ($errCount -gt 0) { $appHeadStats += "$errCount Fehler" } + $openAttr = if ($errCount -gt 0) { " open" } else { "" } + $appBlocksHtml += "
" + + "$(_enc $appName)" + + "$appHeadStats" + + "" + + "
" + + "" + + "" + + "" + + "$rowsHtml
StatusEmpfaengerIntune-GruppeTypMeldung
" + } + if (-not $appBlocksHtml) { + $appBlocksHtml = "
Keine Detail-Eintraege.
" + } + + # Empfaenger-Liste + $recipChipsHtml = "" + foreach ($t in $Targets) { + $isUser = ($t.type -eq 'user') + $cls = if ($isUser) { "chip chip-user" } else { "chip chip-group" } + $modeLbl = if ($isUser) { "User" } else { "Gruppe" } + $entraUrl = if ($isUser) { + "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$([uri]::EscapeDataString([string]$t.id))" + } else { + "https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Overview/groupId/$([uri]::EscapeDataString([string]$t.id))/menuId/" + } + $recipChipsHtml += "" + + "$modeLbl" + + "$(_enc $t.displayName)" + } + + # Zuweisungs-Liste + $assignChipsHtml = "" + foreach ($a in $Assignments) { + $intent = if ($a.type -eq 'Required') { 'req' } else { 'avail' } + $intentLbl = $a.type + $intuneUrl = "https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/2/appId/$([uri]::EscapeDataString([string]$a.appId))" + $assignChipsHtml += "
" + + "$(_enc $a.appName)" + + "
" + + "$(_enc $intentLbl)" + + "$(_enc $a.groupName)" + + "
" + } + if (-not $assignChipsHtml) { $assignChipsHtml = "
Keine Zuweisungen.
" } + + $logHtml = ($Log | ForEach-Object { + $line = _enc $_ + if ($line -like "`[OK`]*") { "$line" } + elseif ($line -like "`[OK*`]*") { "$line" } + elseif ($line -like "`[FAIL`]*") { "$line" } + elseif ($line -like "`[SKIP`]*") { "$line" } + else { $line } + }) -join "`n" + if (-not $logHtml) { $logHtml = "(kein Log)" } + + # Header-Status-Banner + $bannerCls = if ($Errors -gt 0) { "banner-err" } elseif ($Skipped -gt 0 -and $Success -gt 0) { "banner-warn" } else { "banner-ok" } + $bannerTxt = if ($Errors -gt 0) { + "$Errors Fehler aufgetreten - Details unten" + } elseif ($Errors -eq 0 -and $Skipped -gt 0 -and $Success -gt 0) { + "Alle Vorgaenge ohne Fehler. $Skipped uebersprungen." + } elseif ($Errors -eq 0 -and $Success -gt 0) { + "Alle $Success Vorgaenge erfolgreich" + } else { + "Keine ausgefuehrten Vorgaenge" + } + + $html = @" + + + + + +Intune Zuweisungs-Report - $tsHuman + + + + + +
+ +
+
+
+ +
+
Intune Zuweisungs-Report
+
$tsHuman · ausgefuehrt von $(_enc $user)
+
+
+
+"@ + if ($account) { $html += "Account $(_enc $account)" } + if ($tenant) { $html += "Tenant $(_enc $tenant)" } + $html += "Host $(_enc $machine)" + $html += @" +
+
+ +
+ +
+
$Total
Vorgaenge
+
$Success
Erfolgreich
+
$Skipped
Uebersprungen
+
$Errors
Fehler
+
$rate%
Erfolgsquote
$Success von $effective effektiv
+
+ +
+
+
+
Empfaenger
+
$($targetGroups.Count) Gruppe$grpSuffix · $($targetUsers.Count) Benutzer
+
+
+
$recipChipsHtml
+
+ +
+
+
+
Zuweisungen
+
$($Assignments.Count) eindeutige App-Gruppen-Zuweisung$assignSuffix
+
+
+
$assignChipsHtml
+
+ +
+
+
+
Detail-Ergebnisse
+
Gruppiert nach App · Bloecke mit Fehlern sind aufgeklappt
+
+
+ $appBlocksHtml +
+ +
+ Debug-Log ($($Log.Count) Zeilen) +
$logHtml
+
+ +
+ Intune App-Zuweisungs-Manager · Web Edition v$(_enc $script:ToolVersion) · Build $(_enc $script:BuildStamp) +
+
+ + +"@ + [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) + return $path +} diff --git a/Intune/Intune-App-Manager-Web/src/Graph.ps1 b/Intune/Intune-App-Manager-Web/src/Graph.ps1 new file mode 100644 index 0000000..e72d293 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/src/Graph.ps1 @@ -0,0 +1,923 @@ +# Microsoft Graph API Helpers +# Verwendet ausschliesslich Microsoft.Graph.Authentication + Invoke-MgGraphRequest, +# um Versionskonflikte zu vermeiden. + +function Initialize-GraphModule { + if (Get-Module Microsoft.Graph.Authentication) { return $true } + try { + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop -MinimumVersion 2.0.0 + $loaded = Get-Module Microsoft.Graph.Authentication + Write-Host "[GRAPH] Microsoft.Graph.Authentication v$($loaded.Version) geladen" -ForegroundColor DarkGray + return $true + } catch { + $available = @(Get-Module Microsoft.Graph.Authentication -ListAvailable) + if ($available.Count -eq 0) { + throw "Microsoft.Graph.Authentication ist nicht installiert. Bitte ausfuehren: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force" + } + $maxVer = ($available | Sort-Object Version -Descending | Select-Object -First 1).Version + if ($maxVer -lt [version]"2.0.0") { + throw "Microsoft.Graph.Authentication v$maxVer ist zu alt. Mindestens v2.0 noetig. Bitte: Update-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force" + } + throw "Microsoft.Graph.Authentication konnte nicht geladen werden: $($_.Exception.Message)" + } +} + +function Invoke-MgGraphRequestRetry { + # Duenner Wrapper um Invoke-MgGraphRequest mit Retry-Backoff fuer 429/503. + # Microsoft-Best-Practice: Throttling (429) immer abfangen und mit + # Retry-After-Verzoegerung erneut versuchen. 503 analog (Service busy). + # Andere Fehler werden unveraendert durchgereicht (kein Verschlucken). + param( + [Parameter(Mandatory=$true)][string]$Uri, + [string]$Method = 'GET', + $Body, + [string]$ContentType, + [hashtable]$Headers, + [int]$MaxRetries = 3 + ) + $attempt = 0 + while ($true) { + try { + $params = @{ Uri = $Uri; Method = $Method } + if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params.Body = $Body } + if ($ContentType) { $params.ContentType = $ContentType } + if ($Headers) { $params.Headers = $Headers } + return Invoke-MgGraphRequest @params + } catch { + $msg = $_.Exception.Message + $is429 = $msg -match '\b429\b|Too many requests|throttl' + $is503 = $msg -match '\b503\b|Service Unavailable' + if (($is429 -or $is503) -and $attempt -lt $MaxRetries) { + $attempt++ + # Retry-After aus dem Fehler ziehen wenn vorhanden, sonst Backoff 2/5/10s. + $wait = $null + if ($msg -match 'Retry-After[:\s]+(\d+)') { $wait = [int]$matches[1] } + if (-not $wait -or $wait -le 0) { $wait = @(2, 5, 10)[[Math]::Min($attempt - 1, 2)] } + $code = if ($is429) { '429' } else { '503' } + Write-Host " [THROTTLE] $code -> Retry $attempt/$MaxRetries in ${wait}s" -ForegroundColor DarkYellow + Start-Sleep -Seconds $wait + continue + } + throw + } + } +} + +function Get-GraphPaged { + param([Parameter(Mandatory=$true)][string]$Uri) + $results = @() + $next = $Uri + do { + $response = Invoke-MgGraphRequestRetry -Uri $next -Method GET + if ($response.value) { $results += $response.value } + $next = $response.'@odata.nextLink' + } while ($next) + return $results +} + +function Get-GraphGroupByFilter { + param( + [string]$Filter, + [string[]]$Property = @("id","displayName"), + [switch]$ExpandMembers + ) + $select = "`$select=" + ($Property -join ",").ToLower() + $uri = "https://graph.microsoft.com/v1.0/groups?$select" + if ($Filter) { $uri += "&`$filter=$([uri]::EscapeDataString($Filter))" } + # $expand=members($select=id) liefert bis zu 20 Mitglieder-IDs pro Gruppe — + # genug, um "leer ja/nein" zuverlaessig zu erkennen (Graph cap ist 20). + if ($ExpandMembers) { $uri += "&`$expand=members(`$select=id)" } + return Get-GraphPaged -Uri $uri +} + +function Get-GraphGroupById { + param([string]$Id, [string[]]$Property = @("id","displayName")) + $select = "`$select=" + ($Property -join ",").ToLower() + return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$Id`?$select" -Method GET +} + +function Get-GraphGroupMembers { + param([string]$GroupId) + return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName" +} + +function Get-GraphGroupMembersTransitive { + param([string]$GroupId) + return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName" +} + +function Search-GraphUser { + param([string]$SearchTerm) + $term = $SearchTerm -replace "'", "''" + $select = "id,displayName,userPrincipalName,mail,department" + + # Welche Felder durchsucht werden ist konfigurierbar (Settings). + $allowed = @('displayName','userPrincipalName','mail','department') + $cfgFields = @($script:Settings.userSearch.fields | Where-Object { $_ -in $allowed }) + if ($cfgFields.Count -eq 0) { $cfgFields = @('displayName','userPrincipalName','mail') } + + # 1) startswith — schnell, deckt Praefix-Tippen ab (90%+ aller Suchen) + try { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $parts = @($cfgFields | ForEach-Object { "startswith($_,'$term')" }) + $filter = $parts -join " or " + $uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25" + $resp = Invoke-MgGraphRequest -Uri $uri -Method GET + $items = @() + if ($resp -and $resp.value) { $items = @($resp.value) } + $sw.Stop() + Write-Host " -> User-Search '$SearchTerm' [$($cfgFields -join ',')]: startswith=$($items.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray + if ($items.Count -gt 0) { return $items } + } catch { + Write-Host " -> startswith fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow + } + + # 2) Fallback: $search (substring) nur wenn startswith leer war + try { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $searchTerm = $SearchTerm -replace '"', '' + $parts = @($cfgFields | ForEach-Object { "`"$_`:$searchTerm`"" }) + $searchExpr = $parts -join " OR " + $uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$top=25&`$search=$([uri]::EscapeDataString($searchExpr))" + $resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" } + $items = @() + if ($resp -and $resp.value) { $items = @($resp.value) } + $sw.Stop() + Write-Host " -> User-Search '$SearchTerm': `$search-Fallback=$($items.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray + return $items + } catch { + Write-Host " -> `$search-Fallback fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow + return @() + } +} + +function Resolve-GroupNamesBulk { + # Loest viele Gruppen-IDs in einem Schwung via directoryObjects/getByIds auf + # und schreibt {Id => DisplayName} in die uebergebene Hashtable. + param( + [Parameter(Mandatory=$true)][object[]]$Ids, + [Parameter(Mandatory=$true)][hashtable]$Lookup + ) + if (-not $Ids -or $Ids.Count -eq 0) { return } + + # WICHTIG: zu plain strings casten — sonst wickelt ConvertTo-Json (intern in + # Invoke-MgGraphRequest) PSObject-gewrappte Strings in .Chars und crasht mit + # "Self referencing loop". Genau das hat den Batch unbrauchbar gemacht. + $cleanIds = [string[]]@($Ids | ForEach-Object { [string]$_ } | Where-Object { $_ }) + + $batchSize = 800 # API-Limit ist 1000, Puffer fuer Safety + for ($i = 0; $i -lt $cleanIds.Count; $i += $batchSize) { + $end = [Math]::Min($i + $batchSize - 1, $cleanIds.Count - 1) + $chunk = [string[]]$cleanIds[$i..$end] + + try { + # JSON manuell serialisieren — vermeidet jeden Wrapper-Spass + $bodyJson = @{ + ids = $chunk + types = @("group") + } | ConvertTo-Json -Depth 3 -Compress + + $resp = Invoke-MgGraphRequest ` + -Uri "https://graph.microsoft.com/v1.0/directoryObjects/getByIds" ` + -Method POST ` + -Body $bodyJson ` + -ContentType "application/json" + + if ($resp.value) { + foreach ($obj in $resp.value) { + if ($obj.id -and $obj.displayName) { + $Lookup[[string]$obj.id] = [string]$obj.displayName + } + } + } + } catch { + Write-Host " -> getByIds-Batch fehlgeschlagen ($($chunk.Count) IDs): $($_.Exception.Message)" -ForegroundColor DarkYellow + # Fallback nur fuer den fehlgeschlagenen Batch — und auch nur wenn die ID + # noch nicht gesetzt wurde + foreach ($id in $chunk) { + if ($Lookup.ContainsKey($id)) { continue } + try { + $g = Get-GraphGroupById -Id $id -Property @("id","displayName") + if ($g.displayName) { $Lookup[$id] = [string]$g.displayName } + } catch { + $Lookup[$id] = "($id)" + } + } + } + } +} + +function New-GraphSecurityGroup { + param([string]$DisplayName, [string]$MailNickname) + $body = @{ + displayName = $DisplayName + mailEnabled = $false + securityEnabled = $true + mailNickname = $MailNickname + } + return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups" -Method POST -Body $body +} + +function Add-GraphMember { + param([string]$GroupId, [string]$DirectoryObjectId) + # JSON manuell bauen — vermeidet PSObject-Wrapper-Probleme bei der Serialisierung + $bodyJson = '{"@odata.id":"https://graph.microsoft.com/v1.0/directoryObjects/' + $DirectoryObjectId + '"}' + # Out-Null suppressed das $null das Invoke-MgGraphRequest zurueckgibt — sonst + # landet es in der Pipeline des Aufrufers und korrumpiert die Response. + # Ueber den Retry-Wrapper: Member-Adds sind die haeufigste Bulk-Write-Operation + # (Session-Apply mit vielen Empfaengern) und damit am throttle-anfaelligsten. + $null = Invoke-MgGraphRequestRetry ` + -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" ` + -Method POST ` + -Body $bodyJson ` + -ContentType "application/json" +} + +function Get-GraphMobileApps { + param([switch]$WithAssignments) + # WICHTIG: NUR assignments expandieren. categories zusaetzlich auf der + # Vollliste zu expandieren liess Graph hart drosseln (429 -> "Too many + # retries"). Kategorien werden separat + throttle-sicher per $batch + # nachgeladen (Get-GraphMobileAppCategoriesBatch). + $expand = if ($WithAssignments) { "?`$expand=assignments" } else { "" } + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps$expand" + return Get-GraphPaged -Uri $uri +} + +function Get-GraphMobileAppCategoriesBatch { + # Liefert eine Map appId -> @(KategorieNamen) fuer die uebergebenen App-IDs + # ueber Microsoft Graph $batch (20 Sub-Requests pro Batch — API-Limit). + # Throttle-sicher: schlaegt ein Batch fehl, werden dessen Apps einfach + # ohne Kategorien gefuehrt — die App-Liste funktioniert unabhaengig davon. + param([string[]]$AppIds) + $map = @{} + if (-not $AppIds -or $AppIds.Count -eq 0) { return $map } + + $chunkSize = 20 + for ($i = 0; $i -lt $AppIds.Count; $i += $chunkSize) { + $end = [Math]::Min($i + $chunkSize - 1, $AppIds.Count - 1) + $slice = $AppIds[$i..$end] + $requests = @() + foreach ($id in $slice) { + $requests += @{ id = $id; method = 'GET'; url = "/deviceAppManagement/mobileApps/$id/categories" } + } + $body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress + try { + $resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json' + foreach ($r in @($resp.responses)) { + $rid = [string]$r.id + $st = [int]$r.status + if ($st -eq 200 -and $r.body -and $r.body.value) { + $names = @() + foreach ($cat in @($r.body.value)) { + $n = $null + try { if ($cat.displayName) { $n = [string]$cat.displayName } } catch {} + if (-not $n -and $cat -is [System.Collections.IDictionary] -and $cat['displayName']) { $n = [string]$cat['displayName'] } + if ($n) { $names += $n } + } + $map[$rid] = $names + } + } + } catch { + Write-Host " [CATS-BATCH] Batch $i-$end fehlgeschlagen (uebersprungen): $($_.Exception.Message)" -ForegroundColor DarkYellow + } + } + return $map +} + +function Get-GraphMobileAppDetails { + param([Parameter(Mandatory=$true)][string]$AppId) + # Liefert das vollstaendige App-Objekt inkl. typspezifischer Felder + # (Win32: installCommandLine etc., MSI: productCode, Store: licenseType, ...). + return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId" -Method GET +} + +function Add-GraphAppAssignment { + param( + [string]$AppId, + [string]$Intent, # "available" | "required" + [string]$GroupId # GUID oder "ALL_USERS" / "ALL_DEVICES" + ) + $target = switch ($GroupId) { + "ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } } + "ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } } + default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = $GroupId } } + } + $body = @{ + mobileAppAssignments = @(@{ + "@odata.type" = "#microsoft.graph.mobileAppAssignment" + intent = $Intent + target = $target + settings = $null + }) + } + Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body +} + +function Get-GraphMobileAppAssignments { + param([Parameter(Mandatory=$true)][string]$AppId) + $resp = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assignments" -Method GET + if ($resp.value) { return @($resp.value) } + return @() +} + +function Remove-GraphAppAssignmentByGroup { + # Loescht jede Zuweisung der App, deren Target zur uebergebenen Group passt. + # Optional kann ein Intent gesetzt werden, dann werden nur Zuweisungen mit + # genau diesem Intent (required/available) entfernt. + param( + [Parameter(Mandatory=$true)][string]$AppId, + [Parameter(Mandatory=$true)][string]$GroupId, + [string]$Intent + ) + $assignments = Get-GraphMobileAppAssignments -AppId $AppId + $deleted = @() + foreach ($a in $assignments) { + $tgt = $a.target + if (-not $tgt) { continue } + $tgtType = [string]$tgt.'@odata.type' + + $match = $false + switch ($GroupId) { + "ALL_USERS" { if ($tgtType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { $match = $true } } + "ALL_DEVICES" { if ($tgtType -eq '#microsoft.graph.allDevicesAssignmentTarget') { $match = $true } } + default { + if ($tgtType -eq '#microsoft.graph.groupAssignmentTarget' -and [string]$tgt.groupId -eq $GroupId) { $match = $true } + } + } + if (-not $match) { continue } + if ($Intent -and ([string]$a.intent) -ne $Intent) { continue } + + $null = Invoke-MgGraphRequest ` + -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assignments/$($a.id)" ` + -Method DELETE + $deleted += [pscustomobject]@{ + AssignmentId = [string]$a.id + Intent = [string]$a.intent + TargetType = $tgtType + } + } + return $deleted +} + +function Update-GraphMobileApp { + # PATCH auf eine MobileApp. Wichtig: '@odata.type' MUSS im Body sein, sonst + # gibt Graph "ResourceNotSupported" / 400 zurueck (polymorpher Typ). + # Versucht beta, faellt bei 400/404/405 auf v1.0 zurueck. + param( + [Parameter(Mandatory=$true)][string]$AppId, + [Parameter(Mandatory=$true)][string]$AppTypeRaw, # z.B. '#microsoft.graph.win32LobApp' + [Parameter(Mandatory=$true)][hashtable]$Patch # z.B. @{ displayName = 'neuer Name' } + ) + + $body = [ordered]@{} + $body['@odata.type'] = $AppTypeRaw + foreach ($k in $Patch.Keys) { $body[$k] = $Patch[$k] } + $json = $body | ConvertTo-Json -Depth 5 -Compress + + $endpoints = @( + "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId", + "https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$AppId" + ) + $lastErr = $null + foreach ($uri in $endpoints) { + try { + $null = Invoke-MgGraphRequest -Uri $uri -Method PATCH -Body $json -ContentType "application/json" + return + } catch { + $lastErr = $_ + $msg = "$($_.Exception.Message)" + try { $msg += " " + $_.ErrorDetails.Message } catch {} + if ($msg -match '404|400|405|MethodNotAllowed|NotSupported') { continue } + throw + } + } + if ($lastErr) { + $body = $null + try { $body = $lastErr.ErrorDetails.Message } catch {} + $detail = if ($body) { " | Graph-Response: $body" } else { "" } + throw [System.Exception]::new("PATCH fehlgeschlagen: $($lastErr.Exception.Message)$detail") + } +} + +# ============================================================ +# Content-Update: neue Setup-Datei in eine native App hochladen +# (Phase 1: .intunewin / win32LobApp — vorverschluesselt) +# ============================================================ + +function Read-IntuneWinPackage { + # Entpackt eine .intunewin (ZIP) und liest die bereits verschluesselte + # Innen-Datei + die EncryptionInfo aus Metadata/Detection.xml. Es wird + # NICHT neu verschluesselt — IntuneWinAppUtil hat das schon getan. + param([Parameter(Mandatory=$true)][string]$Path) + + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + $detEntry = $archive.Entries | Where-Object { $_.FullName -match 'IntuneWinPackage/Metadata/Detection\.xml$' } | Select-Object -First 1 + if (-not $detEntry) { throw "Detection.xml nicht im .intunewin gefunden — ist die Datei wirklich ein IntuneWinAppUtil-Paket?" } + + # Detection.xml lesen + $sr = New-Object System.IO.StreamReader($detEntry.Open()) + $xmlText = $sr.ReadToEnd(); $sr.Close() + [xml]$xml = $xmlText + $info = $xml.ApplicationInfo + $enc = $info.EncryptionInfo + if (-not $enc) { throw "EncryptionInfo fehlt in Detection.xml" } + + $fileName = [string]$info.FileName # innerer Dateiname (z.B. setup.intunewin) + $unencSize = [int64]$info.UnencryptedContentSize + + # Innere, bereits verschluesselte Datei extrahieren + $contentEntry = $archive.Entries | Where-Object { $_.FullName -match "IntuneWinPackage/Contents/.+" } | Select-Object -First 1 + if (-not $contentEntry) { throw "Verschluesselte Innen-Datei (Contents/) nicht gefunden" } + $ms = New-Object System.IO.MemoryStream + $cs = $contentEntry.Open() + $cs.CopyTo($ms); $cs.Close() + $encryptedBytes = $ms.ToArray(); $ms.Dispose() + + return @{ + EncryptedBytes = $encryptedBytes + Size = $unencSize + SizeEncrypted = [int64]$encryptedBytes.Length + FileName = $fileName + EncryptionInfo = @{ + '@odata.type' = '#microsoft.graph.fileEncryptionInfo' + encryptionKey = [string]$enc.EncryptionKey + macKey = [string]$enc.MacKey + initializationVector = [string]$enc.InitializationVector + mac = [string]$enc.Mac + profileIdentifier = [string]$enc.ProfileIdentifier + fileDigest = [string]$enc.FileDigest + fileDigestAlgorithm = [string]$enc.FileDigestAlgorithm + } + } + } finally { + $archive.Dispose() + } +} + +function New-GraphAppContentVersion { + # Schritt 1: neue contentVersion anlegen. GraphType z.B. 'win32LobApp'. + param([string]$AppId, [string]$GraphType) + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions" + $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body '{}' -ContentType 'application/json' + $cvId = if ($resp.id) { [string]$resp.id } else { [string]$resp.Id } + if (-not $cvId) { throw "contentVersion-Anlage lieferte keine id zurueck" } + return $cvId +} + +function New-GraphAppContentFile { + # Schritt 2: File-Eintrag in der contentVersion anlegen (mit Klartext-Size + + # verschluesselter Size). Liefert fileId. + param( + [string]$AppId, [string]$GraphType, [string]$CvId, + [string]$Name, [int64]$Size, [int64]$SizeEncrypted, $Manifest = $null + ) + $body = [ordered]@{ + '@odata.type' = '#microsoft.graph.mobileAppContentFile' + name = $Name + size = $Size + sizeEncrypted = $SizeEncrypted + isDependency = $false + } + if ($Manifest) { $body['manifest'] = $Manifest } # base64 (MSI/MSIX); win32 = weglassen + $json = $body | ConvertTo-Json -Depth 5 -Compress + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files" + $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json' + $fileId = if ($resp.id) { [string]$resp.id } else { [string]$resp.Id } + if (-not $fileId) { throw "File-Anlage lieferte keine id zurueck" } + return $fileId +} + +function Get-GraphAppContentFile { + param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId) + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId" + return Invoke-MgGraphRequestRetry -Uri $uri -Method GET +} + +function Wait-GraphContentFileState { + # Schritt 3/6: pollt den File-Eintrag bis TargetState erreicht ist. + # Bricht bei *Failed-Zustaenden und Timeout ab. + param( + [string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId, + [string]$TargetState, [int]$TimeoutSec = 180 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while ($true) { + $f = Get-GraphAppContentFile -AppId $AppId -GraphType $GraphType -CvId $CvId -FileId $FileId + $state = if ($f.uploadState) { [string]$f.uploadState } else { [string]$f.uploadstate } + if ($state -eq $TargetState) { return $f } + if ($state -match 'Failed') { throw "Upload-State '$state' (erwartet '$TargetState') — Graph hat den Schritt abgelehnt." } + if ((Get-Date) -gt $deadline) { throw "Timeout beim Warten auf '$TargetState' (letzter State: '$state')." } + Start-Sleep -Milliseconds 1500 + } +} + +function Send-GraphContentToAzureBlob { + # Schritt 4: verschluesselte Bytes als Block-Blob in die SAS-URL laden. + # Chunked (6 MB). WICHTIG: die Bytes werden als ISO-8859-1-String gesendet + # (1:1 Byte<->Zeichen) mit content-type 'text/plain; charset=iso-8859-1' — + # NICHT als rohes byte[]. Invoke-WebRequest verfaelscht in PS 5.1 binaere + # byte[]-Bodies (Bytes > 127), was korrupten Content nach Azure laedt. + # Schema 1:1 aus der MSEndpointMgr/IntuneWin32App-Referenz uebernommen. + param([Parameter(Mandatory=$true)][string]$SasUri, [Parameter(Mandatory=$true)][byte[]]$Bytes) + + $isoEncoding = [System.Text.Encoding]::GetEncoding("iso-8859-1") + $chunkSize = 6 * 1024 * 1024 + $total = $Bytes.Length + $blockIds = New-Object System.Collections.Generic.List[string] + $idx = 0 + for ($offset = 0; $offset -lt $total; $offset += $chunkSize) { + $len = [Math]::Min($chunkSize, $total - $offset) + $chunk = New-Object byte[] $len + [Array]::Copy($Bytes, $offset, $chunk, 0, $len) + # Block-ID: base64 einer 0-gepaddeten Nummer (gleiche Laenge fuer alle) + $blockId = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($idx.ToString('D6'))) + $blockIds.Add($blockId) + $encodedChunk = $isoEncoding.GetString($chunk) + $putUri = "$SasUri&comp=block&blockid=$([uri]::EscapeDataString($blockId))" + $headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'content-type' = 'text/plain; charset=iso-8859-1' } + Invoke-WebRequest -Uri $putUri -Method PUT -Headers $headers -Body $encodedChunk -UseBasicParsing | Out-Null + $idx++ + } + # Block-Liste committen (Invoke-RestMethod, content-type text/plain; charset=UTF-8) + $xml = '' + foreach ($b in $blockIds) { $xml += "$b" } + $xml += '' + $listUri = "$SasUri&comp=blocklist" + Invoke-RestMethod -Uri $listUri -Method PUT -Body $xml -Headers @{ 'content-type' = 'text/plain; charset=UTF-8' } | Out-Null +} + +function Invoke-GraphContentRenewUpload { + param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId) + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId/renewUpload" + $null = Invoke-MgGraphRequestRetry -Uri $uri -Method POST +} + +function Invoke-GraphContentCommit { + # Schritt 5: commit mit fileEncryptionInfo. + param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId, [hashtable]$EncryptionInfo) + $body = @{ fileEncryptionInfo = $EncryptionInfo } | ConvertTo-Json -Depth 5 -Compress + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId/commit" + $null = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $body -ContentType 'application/json' +} + +function Update-GraphAppContent { + # Orchestrator: laedt eine neue Setup-Datei hoch und aktiviert sie. + # Phase 1 unterstuetzt .intunewin (win32LobApp). $ProgressCb (scriptblock) + # optional fuer Schritt-Logging. Gibt das Ergebnis-Objekt zurueck. + param( + [Parameter(Mandatory=$true)][string]$AppId, + [Parameter(Mandatory=$true)][string]$GraphTypeRaw, # z.B. '#microsoft.graph.win32LobApp' + [Parameter(Mandatory=$true)][string]$FilePath, + [string]$DisplayVersion, + [scriptblock]$ProgressCb + ) + $report = { param($step) if ($ProgressCb) { & $ProgressCb $step } ; Write-Host " [CONTENT] $step" -ForegroundColor DarkCyan } + + $graphType = ($GraphTypeRaw -replace '^#microsoft\.graph\.', '') # win32LobApp + $odataCast = "graph.$graphType" + + # 1) Paket lesen (Phase 1: nur .intunewin) + & $report "Lese Paket" + $ext = [IO.Path]::GetExtension($FilePath).ToLower() + if ($ext -ne '.intunewin') { throw "Phase 1 unterstuetzt nur .intunewin. Datei: $ext" } + $pkg = Read-IntuneWinPackage -Path $FilePath + + # 2) contentVersion + file anlegen + & $report "Lege Content-Version an" + $cvId = New-GraphAppContentVersion -AppId $AppId -GraphType $odataCast + $fileId = New-GraphAppContentFile -AppId $AppId -GraphType $odataCast -CvId $cvId -Name $pkg.FileName -Size $pkg.Size -SizeEncrypted $pkg.SizeEncrypted + + # 3) auf SAS warten + & $report "Warte auf Azure-Speicher-URL" + $f = Wait-GraphContentFileState -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -TargetState 'azureStorageUriRequestSuccess' -TimeoutSec 120 + $sas = if ($f.azureStorageUri) { [string]$f.azureStorageUri } else { [string]$f.azurestorageuri } + if (-not $sas) { throw "Keine azureStorageUri erhalten" } + + # 4) Upload zu Azure Blob + & $report "Lade Datei hoch ($([int]($pkg.SizeEncrypted/1MB)) MB)" + Send-GraphContentToAzureBlob -SasUri $sas -Bytes $pkg.EncryptedBytes + + # 5) commit + & $report "Committe Datei" + Invoke-GraphContentCommit -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -EncryptionInfo $pkg.EncryptionInfo + + # 6) auf commit-Erfolg warten + & $report "Warte auf Commit-Bestaetigung" + $null = Wait-GraphContentFileState -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -TargetState 'commitFileSuccess' -TimeoutSec 180 + + # 7) App auf neue Version zeigen (+ optional displayVersion) + & $report "Aktiviere neue Version" + $patch = @{ committedContentVersion = $cvId } + if ($DisplayVersion) { $patch['displayVersion'] = $DisplayVersion } + Update-GraphMobileApp -AppId $AppId -AppTypeRaw $GraphTypeRaw -Patch $patch + + & $report "Fertig" + return @{ ok = $true; contentVersion = $cvId; fileId = $fileId; size = $pkg.Size; sizeEncrypted = $pkg.SizeEncrypted; displayVersion = $DisplayVersion } +} + +function Get-GraphAppDetailsBatch { + # Holt App-Stammdaten + installSummary + relationships in EINEM + # HTTP-Roundtrip via Microsoft Graph $batch. Server-seitig parallelisiert + # — statt 3 sequenzieller Calls (jeder ~300-800ms) nur ein einziger + # Roundtrip in der Zeit des langsamsten Sub-Calls. + # Falls ein Sub-Call serverseitig fehlschlaegt, wird der jeweilige + # Teil $null gesetzt — der Rest funktioniert weiter. + param([Parameter(Mandatory=$true)][string]$AppId) + + $body = @{ + requests = @( + @{ id = 'app'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$($AppId)?`$expand=categories" }, + @{ id = 'summary'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$AppId/installSummary" }, + @{ id = 'rels'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$AppId/relationships" } + ) + } + $json = $body | ConvertTo-Json -Depth 5 -Compress + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $resp = $null + try { + $resp = Invoke-MgGraphRequest ` + -Uri 'https://graph.microsoft.com/beta/$batch' ` + -Method POST ` + -Body $json ` + -ContentType 'application/json' + } catch { + Write-Host " [BATCH] $AppId fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow + return @{ App = $null; InstallSummary = $null; Relationships = @() } + } + $sw.Stop() + + $result = @{ App = $null; InstallSummary = $null; Relationships = @() } + $stati = @() + foreach ($r in @($resp.responses)) { + $st = [int]$r.status + $id = [string]$r.id + $stati += "$id=$st" + switch ($id) { + 'app' { + if ($st -eq 200) { $result.App = $r.body } + } + 'summary' { + if ($st -eq 200) { $result.InstallSummary = $r.body } + } + 'rels' { + if ($st -eq 200 -and $r.body.value) { + $result.Relationships = @($r.body.value) + } + } + } + } + Write-Host " [BATCH] $AppId in $($sw.ElapsedMilliseconds)ms ($($stati -join ', '))" -ForegroundColor DarkGray + return $result +} + +function Get-GraphMobileAppInstallSummary { + # Installations-Statistik einer App. Endpoint ist Singular ("installSummary"), + # nicht zu verwechseln mit deviceStatuses/userStatuses (Per-Device/Per-User). + param([Parameter(Mandatory=$true)][string]$AppId) + try { + return Invoke-MgGraphRequest ` + -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/installSummary" ` + -Method GET + } catch { + return $null + } +} + +function Get-GraphMobileAppRelationships { + # Liefert die Beziehungen einer App (Dependency / Supersedence). 400-Fehler + # beim Delete kommen oft daher, dass eine andere App diese App als Dependency + # oder Supersedence referenziert - dann muss erst die Gegenseite entfernt werden. + param([Parameter(Mandatory=$true)][string]$AppId) + try { + $uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/relationships" + $resp = Invoke-MgGraphRequest -Uri $uri -Method GET + $items = @() + if ($resp.value) { $items = @($resp.value) } + Write-Host " [RELS] $AppId -> $($items.Count) Eintraege via /relationships" -ForegroundColor DarkGray + if ($items.Count -gt 0) { + $byType = $items | Group-Object { [string]$_.'@odata.type' } | ForEach-Object { "$($_.Name)=$($_.Count)" } + Write-Host " [RELS] Typen: $($byType -join ', ')" -ForegroundColor DarkGray + } + return $items + } catch { + Write-Host " [RELS] Lesen fehlgeschlagen fuer $AppId : $($_.Exception.Message)" -ForegroundColor DarkYellow + return @() + } +} + +function Remove-GraphMobileApp { + # Versucht die App ueber beta zu loeschen; fallback auf v1.0 falls beta 400/404 gibt. + # Wirft eine angereicherte Exception mit der Original-Graph-Fehlermeldung. + param([Parameter(Mandatory=$true)][string]$AppId) + + $endpoints = @( + "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId", + "https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$AppId" + ) + $lastErr = $null + foreach ($uri in $endpoints) { + try { + $null = Invoke-MgGraphRequest -Uri $uri -Method DELETE + return # success + } catch { + $lastErr = $_ + # ErrorDetails.Message ist meist der Raw-JSON-Body von Graph + $body = $null + try { $body = $_.ErrorDetails.Message } catch {} + # 405/Resource-not-found auf beta -> v1.0 probieren. Bei 400 ebenfalls + # einen Versuch wagen, das ist im Test mit win32LobApp gelegentlich noetig. + $msg = "$($_.Exception.Message) $body" + if ($msg -match '404|400|405|MethodNotAllowed|NotSupported') { + continue + } else { + throw + } + } + } + # Beide Endpoints haben versagt -> letzte Fehlermeldung anreichern und werfen + if ($lastErr) { + $body = $null + try { $body = $lastErr.ErrorDetails.Message } catch {} + $detail = if ($body) { " | Graph-Response: $body" } else { "" } + throw [System.Exception]::new("Delete fehlgeschlagen: $($lastErr.Exception.Message)$detail") + } +} + +# Excluded App-Typen aus dem Original-Script +$script:ExcludedAppTypes = @( + '#microsoft.graph.iosLobApp', '#microsoft.graph.iosStoreApp', '#microsoft.graph.iosVppApp', + '#microsoft.graph.managedIOSLobApp', '#microsoft.graph.managedIOSStoreApp', + '#microsoft.graph.androidLobApp', '#microsoft.graph.androidStoreApp', '#microsoft.graph.androidForWorkApp', + '#microsoft.graph.androidManagedStoreApp', '#microsoft.graph.androidManagedStoreWebApp', + '#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp', + '#microsoft.graph.macOSDmgApp', '#microsoft.graph.macOSLobApp', '#microsoft.graph.macOSMicrosoftDefenderApp', + '#microsoft.graph.macOSMicrosoftEdgeApp', '#microsoft.graph.macOSOfficeSuiteApp', '#microsoft.graph.macOSPkgApp', + '#microsoft.graph.macOsVppApp' +) + +function Test-AppTypeAllowed { + param([string]$Type) + return -not ($script:ExcludedAppTypes -contains $Type) +} + +function ConvertTo-AppFriendlyType { + param([string]$Type) + switch ($Type) { + '#microsoft.graph.win32LobApp' { 'Win32' } + '#microsoft.graph.windowsStoreApp' { 'Store' } + '#microsoft.graph.microsoftStoreForBusinessApp' { 'MS Store' } + '#microsoft.graph.officeSuiteApp' { 'M365 Apps' } + '#microsoft.graph.windowsMicrosoftEdgeApp' { 'Edge' } + '#microsoft.graph.windowsWebApp' { 'WebApp' } + '#microsoft.graph.winGetApp' { 'WinGet' } + '#microsoft.graph.windowsPhone81AppX' { 'APPX' } + '#microsoft.graph.windowsAppX' { 'APPX' } + '#microsoft.graph.windowsUniversalAppX' { 'APPX' } + '#microsoft.graph.windowsMobileMSI' { 'MSI' } + default { + ($Type -replace '#microsoft\.graph\.','') + } + } +} + +function Get-GraphErrorFriendly { + param([Parameter(Mandatory=$true)]$ErrorRecord) + $msg = $ErrorRecord.Exception.Message + switch -Regex ($msg) { + "already exist|bereits vorhanden|One or more added object references already exist" { + return @{ Code="MemberExists"; Friendly="Mitglied bereits in Gruppe"; IsWarning=$true; Full=$msg } + } + "Insufficient privileges|Access denied|Authorization_RequestDenied|403" { + return @{ Code="Forbidden(403)"; Friendly="Keine Berechtigung. Erforderlich: Group.ReadWrite.All / DeviceManagementApps.ReadWrite.All"; IsWarning=$false; Full=$msg } + } + "does not exist|not found|Request_ResourceNotFound|404" { + return @{ Code="NotFound(404)"; Friendly="Objekt nicht gefunden"; IsWarning=$false; Full=$msg } + } + "Too many requests|throttled|429" { + return @{ Code="Throttled(429)"; Friendly="Zu viele Anfragen. Bitte warten."; IsWarning=$false; Full=$msg } + } + "BadRequest|400" { + if ($msg -match "exist|vorhanden|member") { + return @{ Code="MemberExists"; Friendly="Mitglied bereits in Gruppe"; IsWarning=$true; Full=$msg } + } + return @{ Code="BadRequest(400)"; Friendly="Ungueltige Anfrage"; IsWarning=$false; Full=$msg } + } + default { + return @{ Code="Error"; Friendly=$msg; IsWarning=$false; Full=$msg } + } + } +} + +function Test-AppVendorRule { + # True wenn die App den angegebenen Detection-Regel-Block matcht. + param($RawApp, $Rule) + if (-not $Rule -or -not $Rule.field -or -not $Rule.pattern) { return $false } + $value = switch ($Rule.field) { + 'commandLine' { @($RawApp.installCommandLine, $RawApp.uninstallCommandLine) -join " " } + 'developer' { [string]$RawApp.developer } + 'publisher' { [string]$RawApp.publisher } + 'displayName' { [string]$RawApp.displayName } + 'notes' { [string]$RawApp.notes } + 'owner' { [string]$RawApp.owner } + default { "" } + } + if (-not $value) { return $false } + switch ($Rule.match) { + 'equals' { return ($value -eq $Rule.pattern) } + 'startsWith' { return $value.StartsWith($Rule.pattern, [StringComparison]::OrdinalIgnoreCase) } + 'regex' { + try { return ($value -match $Rule.pattern) } + catch { return $false } # ungueltiger Regex -> Vendor matcht halt nicht + } + default { return ($value -match [regex]::Escape($Rule.pattern)) } # 'contains' + } +} + +function Resolve-AppVendorSource { + # Iteriert ueber Settings.vendors und liefert displayName des ersten + # passenden Vendors. Default: "Intune" wenn keiner matcht. + param($RawApp, $Vendors) + if ($null -eq $Vendors) { return "Intune" } + foreach ($v in @($Vendors)) { + if ($v -and (Test-AppVendorRule -RawApp $RawApp -Rule $v.detection)) { + return [string]$v.displayName + } + } + return "Intune" +} + +function Format-AppForFrontend { + param($RawApp, $AllGroupsLookup) + # AllGroupsLookup: hashtable groupId -> displayName (fuer Anzeige der zugewiesenen Gruppen) + $available = @() + $required = @() + foreach ($a in @($RawApp.assignments)) { + $tgt = $a.target + $tgtType = $tgt.'@odata.type' + $entry = $null + if ($tgtType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { + $entry = @{ GroupId="ALL_USERS"; GroupName="All Users"; IsNative=$true } + } elseif ($tgtType -eq '#microsoft.graph.allDevicesAssignmentTarget') { + $entry = @{ GroupId="ALL_DEVICES"; GroupName="All Devices"; IsNative=$true } + } elseif ($tgtType -eq '#microsoft.graph.groupAssignmentTarget') { + $gid = $tgt.groupId + $name = if ($AllGroupsLookup -and $AllGroupsLookup.ContainsKey($gid)) { $AllGroupsLookup[$gid] } else { $gid } + $entry = @{ GroupId=$gid; GroupName=$name; IsNative=$false } + } elseif ($tgtType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { + continue + } + if ($null -ne $entry) { + switch ($a.intent) { + 'available' { $available += $entry } + 'required' { $required += $entry } + } + } + } + # Version aus moeglichen Feldern (je nach App-Typ) + $version = $null + foreach ($f in @('displayVersion','version','productVersion','identityVersion','officeSuiteAppVersion')) { + if ($RawApp.$f) { $version = [string]$RawApp.$f; break } + } + + # Quelle: PMPC-Direkterkennung zuerst (das hat schon immer funktioniert, + # bleibt damit auch bei kaputter Settings/Vendor-Registry zuverlaessig). + # Erst wenn das nicht greift, fragen wir die Vendor-Registry — dort liegen + # Robopack & weitere konfigurierbare Vendoren. + $source = "Intune" + if ($RawApp.installCommandLine -and $RawApp.installCommandLine -match "PatchMyPC") { + $source = "PatchMyPC" + } elseif ($RawApp.uninstallCommandLine -and $RawApp.uninstallCommandLine -match "PatchMyPC") { + $source = "PatchMyPC" + } else { + $vendorSource = Resolve-AppVendorSource -RawApp $RawApp -Vendors $script:Settings.vendors + if ($vendorSource -and $vendorSource -ne "Intune" -and $vendorSource -ne "PatchMyPC") { + $source = $vendorSource + } + } + + return [pscustomobject]@{ + AppId = $RawApp.id + AppName = $RawApp.displayName + AppType = ConvertTo-AppFriendlyType -Type $RawApp.'@odata.type' + AppTypeRaw = $RawApp.'@odata.type' + Publisher = $RawApp.publisher + Version = $version + Source = $source + IsAssigned = ($RawApp.isAssigned -eq $true) + AvailableGroups = $available + RequiredGroups = $required + AvailableCount = $available.Count + RequiredCount = $required.Count + # Kategorien fuer Filter + Anzeige. Get-AppCategoryNames liegt in Api.ps1, + # ist aber global dot-sourced. Liefert immer ein String-Array (ggf. leer). + Categories = (Get-AppCategoryNames -RawCategories $RawApp.categories -AppId $RawApp.id) + # ISO-Strings damit JS new Date() es zuverlaessig parst — fuer Filter + # nach Aenderungs-Zeitraum (App-Liste, ohne Detail-Roundtrip). + CreatedDateTime = ConvertTo-IsoDate $RawApp.createdDateTime + LastModifiedDateTime = ConvertTo-IsoDate $RawApp.lastModifiedDateTime + } +} diff --git a/Intune/Intune-App-Manager-Web/src/Models.ps1 b/Intune/Intune-App-Manager-Web/src/Models.ps1 new file mode 100644 index 0000000..ae258ca --- /dev/null +++ b/Intune/Intune-App-Manager-Web/src/Models.ps1 @@ -0,0 +1,295 @@ +# Datenklassen / Hilfen fuer Frontend-JSON + +function ConvertTo-Json2 { + param([Parameter(ValueFromPipeline=$true)]$InputObject, [int]$Depth = 12) + process { + return $InputObject | ConvertTo-Json -Depth $Depth -Compress + } +} + +# Normalisiert ein potentielles Datum (DateTime, DateTimeOffset, String, +# /Date(ms)/, $null) auf ein ISO-8601-String — damit JS' new Date() es +# zuverlaessig parsen kann. +function ConvertTo-IsoDate { + param($Value) + if ($null -eq $Value) { return $null } + if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('o') } + if ($Value -is [DateTimeOffset]) { return $Value.UtcDateTime.ToString('o') } + $s = [string]$Value + if ([string]::IsNullOrWhiteSpace($s)) { return $null } + # Microsoft-Legacy "/Date(1234567890123)/" + if ($s -match '^\/Date\((-?\d+)') { + try { return ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$matches[1])).UtcDateTime.ToString('o') } catch {} + } + # Schon ein parsbares Datum? + try { return ([DateTime]$s).ToUniversalTime().ToString('o') } catch {} + return $s +} + +# ============================================================ +# Settings: editierbare Konfiguration, persistiert als JSON +# unter %APPDATA%\IntuneAppManager-Web\settings.json +# ============================================================ + +function Get-SettingsPath { + $dir = Join-Path $env:APPDATA "IntuneAppManager-Web" + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + return Join-Path $dir "settings.json" +} + +function Get-DefaultSettings { + # Tenant-spezifische Felder (tenantId, clientId, departments.prefix, + # rpa.groupNames) sind absichtlich leer — eine frische Installation + # zwingt den Admin durchs Setup. Generische Werte (Scopes, Naming-Schemas, + # Theme) sind branchenuebliche Startpunkte und bleiben besetzt. + return [pscustomobject]@{ + connection = [pscustomobject]@{ + tenantId = "" + clientId = "" + # DeviceManagementApps.ReadWrite.All ist Pflicht: assign (native All + # Users/Devices), PATCH (Rename) und DELETE auf mobileApps brauchen + # ReadWrite — Read.All allein liefert dort 403. Verifiziert via + # msgraph-Skill gegen den offiziellen Graph-Permission-Index. + scopes = @("Group.ReadWrite.All", "GroupMember.ReadWrite.All", "User.Read.All", "DeviceManagementApps.ReadWrite.All") + } + departments = [pscustomobject]@{ + # Ein oder mehrere Praefixe fuer den Abteilungs-Modus (linke Spalte). + # Mehrfach moeglich (z.B. "abt-hm" + "abt-extern"). Pflicht: mindestens + # ein Eintrag im Setup. Der alte Single-String 'prefix' bleibt fuer + # Rueckwaerts-Kompatibilitaet — Get-DepartmentPrefixes liest beides. + prefixes = @() + prefix = "" + } + rpa = [pscustomobject]@{ + # Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis. + groupNames = @() + } + userSearch = [pscustomobject]@{ + # In welchen Feldern bei der Benutzersuche gesucht wird. + # Erlaubt: displayName, userPrincipalName, mail, department. + fields = @("displayName", "userPrincipalName", "mail") + } + requiredGroupNaming = [pscustomobject]@{ + # Wird beim "+ Required-Gruppe"-Workflow verwendet: + # {prefix}{slug-vom-app-name}{suffix} + prefix = "intune-win-app-" + suffix = "-required" + } + availableGroupNaming = [pscustomobject]@{ + # Analog zu requiredGroupNaming — fuer "+ Available-Gruppe"-Workflow. + prefix = "intune-win-app-" + suffix = "-available" + } + branding = [pscustomobject]@{ + # Logo-Dateiname relativ zum Projekt-Root (dort liegen auch + # intune.png/pmpc.png/application.png). $null = Letter "I" Fallback. + logoFile = "application.png" + } + # Vendor-Registry: jede App wird gegen diese Liste in Reihenfolge + # gepruef; erster Match gewinnt. Source-String im App-Objekt = + # vendor.displayName (sonst "Intune"). Tenants koennen Detection- + # Regeln, Portal-URLs und Blocking pro Vendor in settings.json + # ueberschreiben. + vendors = @( + [pscustomobject]@{ + id = "patchmypc" + displayName = "PatchMyPC" + portalUrl = "https://portal.patchmypc.com/" + logoFile = "pmpc.png" + detection = [pscustomobject]@{ + field = "commandLine" # commandLine | developer | publisher | displayName | notes | owner + match = "contains" # contains | equals | regex | startsWith + pattern = "PatchMyPC" + } + block = [pscustomobject]@{ delete = $true; rename = $true } + }, + [pscustomobject]@{ + id = "robopack" + displayName = "Robopack" + portalUrl = "https://app.robopack.com/" + logoFile = "robopack.png" + detection = [pscustomobject]@{ + field = "developer" + match = "equals" + pattern = "Robopack" + } + block = [pscustomobject]@{ delete = $true; rename = $true } + } + ) + theme = [pscustomobject]@{ + # Hauptfarben fuer Highlights. Soft/Strong/Border/Bg werden im + # Frontend per rgba() aus dem Hex abgeleitet. + colors = [pscustomobject]@{ + brand = "#27a078" # Primaere Firmenfarbe: Logo-Hintergrund, Stepper-Indikator + accent = "#3b82f6" # Available-Pillen, Links, Akzent-Hover + required = "#cb2a7a" # Pink (Required-Badges) + success = "#10b981" # Bereits zugewiesen + warning = "#f59e0b" # Teilweise / Skip + error = "#ef4444" # Fehler / Destructive + rowSelected = "#71e5c4" # Hintergrund der aufgeklappten/markierten App-Zeile + detailPanel = "#f7f7f7" # Hintergrund des Details-Bereichs unter der App + } + } + } +} + +# Tiefer Merge: gespeicherte Werte ueberschreiben Defaults, neue Default- +# Keys werden trotzdem ergaenzt — robust gegen Settings-Schema-Erweiterungen. +function Merge-Settings { + param($Base, $Override) + if ($null -eq $Override) { return $Base } + $result = [ordered]@{} + foreach ($p in $Base.PSObject.Properties) { + $name = $p.Name + $bv = $p.Value + $ov = $null + $hasOverride = $false + if ($Override.PSObject.Properties[$name]) { + $ov = $Override.PSObject.Properties[$name].Value + $hasOverride = $true + } + if (-not $hasOverride) { + $result[$name] = $bv + } elseif ($bv -is [pscustomobject] -and $ov -is [pscustomobject]) { + $result[$name] = Merge-Settings -Base $bv -Override $ov + } else { + $result[$name] = $ov + } + } + # Zusaetzliche Properties aus Override, die nicht im Base sind, ignorieren — + # sie wuerden vom Backend ohnehin nicht gelesen. + return [pscustomobject]$result +} + +function Get-DepartmentPrefixes { + # Zentrale Quelle fuer die Abteilungs-Praefixe. Bevorzugt das neue + # 'prefixes'-Array; faellt sonst auf den alten Single-String 'prefix' + # zurueck (Rueckwaerts-Kompatibilitaet mit existierenden settings.json). + # Liefert immer ein String-Array (getrimmt, dedupliziert, ohne leere + # Eintraege), ggf. leer wenn nichts konfiguriert ist. + param($Settings) + $out = [System.Collections.Generic.List[string]]::new() + if ($null -eq $Settings -or $null -eq $Settings.departments) { return ,$out.ToArray() } + $d = $Settings.departments + $candidates = @() + if ($d.PSObject.Properties['prefixes']) { $candidates += @($d.prefixes) } + if ($d.PSObject.Properties['prefix'] -and $d.prefix) { $candidates += @([string]$d.prefix) } + $seen = @{} + foreach ($p in $candidates) { + if ($null -eq $p) { continue } + $t = ([string]$p).Trim() + if (-not $t) { continue } + $k = $t.ToLowerInvariant() + if ($seen.ContainsKey($k)) { continue } + $seen[$k] = $true + $out.Add($t) + } + return $out.ToArray() +} + +function Read-Settings { + $path = Get-SettingsPath + $defaults = Get-DefaultSettings + if (-not (Test-Path $path)) { return $defaults } + try { + $raw = Get-Content -Path $path -Raw -Encoding UTF8 + if ([string]::IsNullOrWhiteSpace($raw)) { return $defaults } + $saved = $raw | ConvertFrom-Json + return Merge-Settings -Base $defaults -Override $saved + } catch { + Write-Host "[SETTINGS] Lesen fehlgeschlagen, verwende Defaults: $($_.Exception.Message)" -ForegroundColor Yellow + return $defaults + } +} + +function Write-Settings { + param([Parameter(Mandatory=$true)]$Settings) + $path = Get-SettingsPath + $json = $Settings | ConvertTo-Json -Depth 10 + [IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8) + Write-Host "[SETTINGS] Gespeichert: $path" -ForegroundColor DarkGray +} + +function Get-SettingsValidationErrors { + # Rudimentaere Validierung. Schwere Fehler -> Speichern ablehnen. + param($S) + $errs = @() + if (-not $S.connection.tenantId -or $S.connection.tenantId -notmatch '^[0-9a-fA-F-]{36}$') { + $errs += "Tenant ID muss eine GUID sein." + } + if (-not $S.connection.clientId -or $S.connection.clientId -notmatch '^[0-9a-fA-F-]{36}$') { + $errs += "Client ID muss eine GUID sein." + } + if (-not $S.connection.scopes -or @($S.connection.scopes).Count -eq 0) { + $errs += "Mindestens ein Scope erforderlich." + } + $deptPrefixes = Get-DepartmentPrefixes -Settings $S + if ($deptPrefixes.Count -eq 0) { + $errs += "Mindestens ein Abteilungs-Praefix erforderlich." + } else { + $bad = @($deptPrefixes | Where-Object { $_.Trim().Length -lt 2 }) + if ($bad.Count -gt 0) { $errs += "Abteilungs-Praefixe muessen jeweils mindestens 2 Zeichen lang sein." } + } + if (-not $S.rpa.groupNames -or @($S.rpa.groupNames).Count -eq 0) { + $errs += "Mindestens eine RPA-Gruppe erforderlich." + } + $allowedUserFields = @('displayName','userPrincipalName','mail','department') + $bad = @($S.userSearch.fields | Where-Object { $_ -notin $allowedUserFields }) + if ($bad.Count -gt 0) { $errs += "Unbekannte User-Search-Felder: $($bad -join ', ')" } + if (-not $S.userSearch.fields -or @($S.userSearch.fields).Count -eq 0) { + $errs += "Mindestens ein User-Such-Feld erforderlich." + } + if (-not $S.requiredGroupNaming.prefix -or -not $S.requiredGroupNaming.suffix) { + $errs += "Required-Gruppen-Naming: Praefix und Suffix erforderlich." + } + if ($S.availableGroupNaming -and (-not $S.availableGroupNaming.prefix -or -not $S.availableGroupNaming.suffix)) { + $errs += "Available-Gruppen-Naming: Praefix und Suffix erforderlich." + } + # Vendor-Registry: jeder Eintrag muss id + detection mit field/match/pattern haben. + if ($null -ne $S.vendors) { + $allowedFields = @('commandLine','developer','publisher','displayName','notes','owner') + $allowedMatches = @('contains','equals','regex','startsWith') + $seenIds = @{} + foreach ($v in @($S.vendors)) { + if (-not $v.id -or [string]::IsNullOrWhiteSpace($v.id)) { $errs += "Vendor: 'id' erforderlich."; continue } + if ($seenIds.ContainsKey($v.id)) { $errs += "Vendor '$($v.id)': id ist nicht eindeutig."; continue } + $seenIds[$v.id] = $true + if (-not $v.displayName) { $errs += "Vendor '$($v.id)': displayName erforderlich." } + if (-not $v.detection) { $errs += "Vendor '$($v.id)': detection-Block fehlt."; continue } + if ($v.detection.field -notin $allowedFields) { $errs += "Vendor '$($v.id)': detection.field muss eines von $($allowedFields -join '|') sein." } + if ($v.detection.match -notin $allowedMatches) { $errs += "Vendor '$($v.id)': detection.match muss eines von $($allowedMatches -join '|') sein." } + if (-not $v.detection.pattern -or [string]::IsNullOrWhiteSpace($v.detection.pattern)) { $errs += "Vendor '$($v.id)': detection.pattern erforderlich." } + } + } + # Theme-Farben sind Hex-Strings (#RGB oder #RRGGBB) + if ($S.theme -and $S.theme.colors) { + foreach ($key in @('brand','accent','required','success','warning','error','rowSelected','detailPanel')) { + $val = $S.theme.colors.$key + if ($val -and $val -notmatch '^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') { + $errs += "Farbe '$key' ist kein gueltiger Hex-Wert." + } + } + } + return $errs +} + +function New-AssignmentItem { + param( + [string]$AppId, + [string]$AppName, + [string]$AppType, + [string]$Type, # "Available" | "Required" + [string]$GroupId, + [string]$GroupName + ) + return [pscustomobject]@{ + Id = [guid]::NewGuid().ToString() + AppId = $AppId + AppName = $AppName + AppType = $AppType + Type = $Type + GroupId = $GroupId + GroupName = $GroupName + AddedAt = (Get-Date).ToString("o") + } +} diff --git a/Intune/Intune-App-Manager-Web/src/Router.ps1 b/Intune/Intune-App-Manager-Web/src/Router.ps1 new file mode 100644 index 0000000..10a05f2 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/src/Router.ps1 @@ -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") + } +} diff --git a/Intune/Intune-App-Manager-Web/src/Server.ps1 b/Intune/Intune-App-Manager-Web/src/Server.ps1 new file mode 100644 index 0000000..ffed947 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/src/Server.ps1 @@ -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) +} diff --git a/Intune/Intune-App-Manager-Web/www/app.js b/Intune/Intune-App-Manager-Web/www/app.js new file mode 100644 index 0000000..edbf508 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/app.js @@ -0,0 +1,4168 @@ +// ============================================================= +// Intune App-Manager — Frontend +// Workflow: Connect → Select Targets → Pick Apps → Apply +// ============================================================= + +const State = { + connected: false, + account: null, + mode: 'dept', // 'dept' | 'rpa' | 'user' + targets: [], + selectedTargetIds: new Set(), + apps: [], + appFilter: { search: '', preset: 'all', category: '', onlyMember: false, hideNative: false, modFrom: '', modTo: '' }, + session: [], + membershipCache: new Map(), + loadedModes: new Set(), + appsLoaded: false, + sideTab: 'session', + // groupId → { members:Array|null, selected:Set, loading:bool, error:string|null, filter:string } + expandedGroups: new Map(), + // mode → { targets, selectedIds:Set, expandedGroups:Map } — verhindert Verlust beim Tab-Wechsel + modeCache: new Map(), + // appId → { details, loading, error } + appDetails: new Map(), + expandedApps: new Set(), +}; + +// ============================================================= +// API wrapper +// ============================================================= + +async function api(path, options = {}) { + const opts = { + method: options.method || 'GET', + headers: { 'Content-Type': 'application/json' }, + body: options.body ? JSON.stringify(options.body) : undefined, + }; + // Optionaler Timeout (z.B. fuer lange Upload-Operationen). fetch hat sonst + // keinen Default-Timeout — lauft auf localhost ewig, was hier ok ist. + let timer = null; + if (options.timeoutMs) { + const ctrl = new AbortController(); + opts.signal = ctrl.signal; + timer = setTimeout(() => ctrl.abort(), options.timeoutMs); + } + let res; + try { + res = await fetch(path, opts); + } finally { + if (timer) clearTimeout(timer); + } + let data; + try { data = await res.json(); } catch { data = {}; } + if (!res.ok) { + const msg = data.error || `HTTP ${res.status}`; + throw new Error(msg); + } + return data; +} + +// ============================================================= +// Toasts +// ============================================================= + +function toast(msg, type = 'info', title = null) { + const ico = { ok: '✓', warn: '!', err: '×', info: 'i' }[type] || 'i'; + const t = document.createElement('div'); + t.className = `toast ${type}`; + t.innerHTML = ` +
${ico}
+
+ ${title ? `
${escapeHtml(title)}
` : ''} +
${escapeHtml(msg)}
+
+ `; + document.getElementById('toasts').appendChild(t); + setTimeout(() => { + t.style.animation = 'slide-in 200ms reverse'; + setTimeout(() => t.remove(), 220); + }, type === 'err' ? 6000 : 3500); +} + +function escapeHtml(s) { + if (s == null) return ''; + return String(s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); +} + +// Markdown -> HTML. Nutzt marked (gebundelt in /vendor/marked.min.js). +// breaks: true -> einzelne Newlines werden zu
, intuitiver fuer User +// gfm: true -> GitHub-Flavored Markdown (Tabellen, Strikethrough, Autolinks) +// Wenn marked aus irgendeinem Grund nicht geladen ist, faellt der Helper +// auf escapeHtml zurueck — kein Crash, nur kein Markdown. +let markedConfigured = false; +function renderMarkdown(s) { + if (s == null) return ''; + const text = String(s); + if (typeof window.marked === 'undefined') return escapeHtml(text); + if (!markedConfigured) { + window.marked.setOptions({ breaks: true, gfm: true }); + markedConfigured = true; + } + try { return window.marked.parse(text); } + catch { return escapeHtml(text); } +} + +// ============================================================= +// Loading overlay +// ============================================================= + +let loadingDepth = 0; +function setLoading(text) { + loadingDepth++; + document.getElementById('loadingText').textContent = text || 'Bitte warten...'; + document.getElementById('loading').classList.remove('hidden'); +} +function clearLoading() { + loadingDepth = Math.max(0, loadingDepth - 1); + if (loadingDepth === 0) document.getElementById('loading').classList.add('hidden'); +} + +// ============================================================= +// Workflow stepper +// ============================================================= + +// Stepper wurde entfernt — Funktion bleibt als No-Op, damit die ~12 +// bestehenden Aufrufer keine Errors werfen. Kann irgendwann mit Sed +// gemeinsam mit den Aufrufstellen rausgesnipt werden. +function updateStepper() { /* no-op */ } + +// ============================================================= +// Connection +// ============================================================= + +function showOnboarding(show) { + document.getElementById('onboarding').classList.toggle('hidden', !show); + document.getElementById('layout').classList.toggle('hidden', show); + if (show) { + // Reset state-View nur, wenn gerade keine Anmeldung lief + if (!connectInFlight) showOnboardingState('idle'); + } else { + stopDevicePolling(); + } +} + +async function refreshStatus() { + try { + const s = await api('/api/status'); + State.connected = s.connected; + State.account = s.account; + renderConnection(); + } catch (e) { + toast('Status konnte nicht geladen werden: ' + e.message, 'err'); + } +} + +// Bootstrap-Icons "person-circle" — Avatar mit eingebautem Kreis-Outline. +// 20px matches die visuelle Groesse der anderen Header-Icons (cog, logout). +const PERSON_ICON = ``; + +function renderConnection() { + const pill = document.getElementById('connStatus'); + const text = pill.querySelector('.conn-text'); + const btnD = document.getElementById('btnDisconnect'); + + // Avatar ist der Person-Icon — Identifikation des aktiven Accounts laeuft + // ueber den Tooltip (title-Attribut, hover). + text.innerHTML = PERSON_ICON; + + if (State.connected) { + pill.className = 'conn-pill conn-on'; + pill.title = State.account || 'Verbunden'; + btnD.classList.remove('hidden'); + showOnboarding(false); + } else { + pill.className = 'conn-pill conn-off'; + pill.title = 'Nicht verbunden'; + btnD.classList.add('hidden'); + showOnboarding(true); + } + updateStepper(); +} + +// ---------------------------------------------------------------- +// Device-Code-Login: Anmeldung direkt auf der Seite, ohne Popup- +// Fenster, ohne WAM. Nutzer kann jeden Account angeben. +// ---------------------------------------------------------------- + +let connectInFlight = false; +let devicePollTimer = null; + +function showOnboardingState(view) { + // view: 'idle' | 'requesting' | 'awaiting' | 'error' + document.querySelectorAll('[data-onboarding-view]').forEach(el => { + el.classList.toggle('hidden', el.dataset.onboardingView !== view); + }); +} + +function stopDevicePolling() { + if (devicePollTimer) { clearInterval(devicePollTimer); devicePollTimer = null; } +} + +document.getElementById('btnConnect').addEventListener('click', async () => { + if (connectInFlight) return; + connectInFlight = true; + + showOnboardingState('awaiting'); + + // Sekundenzaehler im UI + const startedAt = Date.now(); + const waitText = document.querySelector('[data-onboarding-view="awaiting"] .onboarding-text'); + const baseText = waitText ? waitText.textContent : ''; + const tickTimer = setInterval(() => { + if (waitText) { + const sec = Math.floor((Date.now() - startedAt) / 1000); + waitText.textContent = `${baseText} (Wartet seit ${sec}s)`; + } + }, 1000); + + // 90s Timeout — Connect-MgGraph blockt typisch 5-30s; 90s ist sicher + const ctrl = new AbortController(); + const timeoutId = setTimeout(() => ctrl.abort(), 90000); + + try { + const resp = await fetch('/api/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: ctrl.signal, + }); + clearTimeout(timeoutId); + let s = {}; + try { s = await resp.json(); } catch {} + if (!resp.ok) throw new Error(s.error || `HTTP ${resp.status}`); + if (!s.connected) throw new Error('Backend meldet nicht connected'); + + State.connected = true; + State.account = s.account; + renderConnection(); + toast('Verbunden als ' + s.account, 'ok', 'Erfolgreich'); + autoLoadAfterConnect(); + } catch (e) { + clearTimeout(timeoutId); + let msg = e.message; + if (e.name === 'AbortError') { + msg = 'Anmeldung dauert über 90s. Möglicherweise ist das Login-Fenster im Hintergrund — prüfe die Taskleiste. Sonst PowerShell-Konsole prüfen.'; + } + document.getElementById('dcError').textContent = msg; + showOnboardingState('error'); + toast(msg, 'err', 'Verbindung fehlgeschlagen'); + } finally { + clearInterval(tickTimer); + if (waitText) waitText.textContent = baseText; + connectInFlight = false; + } +}); + +document.getElementById('btnCancelConnect')?.addEventListener('click', async () => { + stopDevicePolling(); + try { await api('/api/connect/cancel', { method: 'POST' }); } catch {} + showOnboardingState('idle'); + connectInFlight = false; +}); + +document.getElementById('btnRetryConnect')?.addEventListener('click', () => { + showOnboardingState('idle'); + connectInFlight = false; +}); + +document.getElementById('btnDisconnect').addEventListener('click', async () => { + setLoading('Trenne...'); + stopDevicePolling(); + connectInFlight = false; + try { + await api('/api/disconnect', { method: 'POST' }); + Object.assign(State, { + connected: false, account: null, + targets: [], apps: [], session: [], + appsLoaded: false, + }); + State.selectedTargetIds.clear(); + State.membershipCache.clear(); + State.loadedModes.clear(); + State.appDetails.clear(); + State.expandedApps.clear(); + State.modeCache.clear(); + renderConnection(); + renderTargets(); + renderPinnedTargets(); + renderApps(); + renderSession(); + renderExisting(); + toast('Verbindung getrennt', 'info'); + } finally { + clearLoading(); + } +}); + +async function autoLoadAfterConnect() { + // Load default mode targets + apps in parallel + const tasks = []; + if (!State.loadedModes.has(State.mode)) tasks.push(loadTargets({ silent: true })); + if (!State.appsLoaded) tasks.push(loadApps(false, { silent: true })); + await Promise.allSettled(tasks); +} + +// ============================================================= +// Mode tabs (segmented control) +// ============================================================= + +document.querySelectorAll('.seg').forEach(seg => { + seg.addEventListener('click', () => { + if (seg.classList.contains('active')) return; + const oldMode = State.mode; + const newMode = seg.dataset.mode; + + // Aktuellen Stand fuer alten Mode sichern + State.modeCache.set(oldMode, { + targets: State.targets.slice(), + selectedIds: new Set(State.selectedTargetIds), + expandedGroups: State.expandedGroups, + }); + + document.querySelectorAll('.seg').forEach(s => s.classList.remove('active')); + seg.classList.add('active'); + State.mode = newMode; + + // Stand fuer neuen Mode wiederherstellen — falls da + const cached = State.modeCache.get(newMode); + if (cached) { + State.targets = cached.targets; + State.selectedTargetIds = new Set(cached.selectedIds); + State.expandedGroups = cached.expandedGroups; + } else { + State.targets = []; + State.selectedTargetIds = new Set(); + State.expandedGroups = new Map(); + } + // Membership-Cache leeren da Targets sich aendern → andere Mitgliedschafts-Resultate + State.membershipCache.clear(); + + const search = document.getElementById('targetSearch'); + const hint = document.getElementById('userSearchHint'); + if (newMode === 'user') { + search.placeholder = 'Tippe Name oder UPN...'; + hint.classList.remove('hidden'); + // Suchwert bei User-Mode immer leer starten + search.value = ''; + search.focus(); + try { search.setSelectionRange(0, 0); } catch {} + } else { + search.placeholder = 'Filtern...'; + hint.classList.add('hidden'); + search.value = ''; + } + + renderTargets(); + onTargetSelectionChange(); + + // Auto-Load nur wenn fuer diesen Mode noch nichts geladen wurde UND es kein User-Mode ist + if (State.connected && newMode !== 'user' && !State.loadedModes.has(newMode) && State.targets.length === 0) { + loadTargets({ silent: false }); + } + }); +}); + +// ============================================================= +// Targets +// ============================================================= + +document.getElementById('btnReloadTargets').addEventListener('click', () => { + if (State.mode === 'user') { + const term = document.getElementById('targetSearch').value; + if (!term || term.length < 2) { + document.getElementById('targetSearch').focus(); + toast('Tippe mind. 2 Zeichen für Benutzersuche', 'warn'); + return; + } + runUserSearch(term); + } else { + State.loadedModes.delete(State.mode); + loadTargets({ silent: false }); + } +}); + +document.getElementById('targetSearch').addEventListener('input', e => { + if (State.mode === 'user') { + debouncedUserSearch(e.target.value); + } else { + renderTargets(); + } +}); + +// Enter key triggers explicit user search +document.getElementById('targetSearch').addEventListener('keydown', e => { + if (e.key === 'Enter' && State.mode === 'user') { + const term = e.target.value; + if (term && term.length >= 2) runUserSearch(term); + } +}); + +document.getElementById('btnTargetsSelectAll').addEventListener('click', () => { + getFilteredTargets().forEach(t => State.selectedTargetIds.add(t.Id)); + renderTargets(); + onTargetSelectionChange(); +}); + +document.getElementById('btnTargetsClear').addEventListener('click', () => { + State.selectedTargetIds.clear(); + renderTargets(); + onTargetSelectionChange(); +}); + +let userSearchTimer = null; +let userSearchAbort = null; +let userSearchCache = { query: '', results: [] }; + +function debouncedUserSearch(term) { + clearTimeout(userSearchTimer); + const t = (term || '').trim(); + if (t.length < 2) { + State.targets = []; + userSearchCache = { query: '', results: [] }; + renderTargets(); + return; + } + + // Optimistic: wenn neue Anfrage Verfeinerung der vorigen ist + // → sofort lokal filtern, kein Server-Request, NULL Latenz + const lower = t.toLowerCase(); + const cachedLower = userSearchCache.query.toLowerCase(); + if (cachedLower && lower.startsWith(cachedLower) && userSearchCache.results.length > 0) { + State.targets = userSearchCache.results.filter(u => + (u.DisplayName || '').toLowerCase().includes(lower) || + (u.UserPrincipalName || '').toLowerCase().includes(lower) || + (u.Mail || '').toLowerCase().includes(lower)); + renderTargets(); + return; + } + + userSearchTimer = setTimeout(() => runUserSearch(t), 220); +} + +async function runUserSearch(term) { + if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } + + // Vorherige Anfrage abbrechen — sonst stapeln sich die Requests beim Tippen + if (userSearchAbort) { + try { userSearchAbort.abort(); } catch {} + } + userSearchAbort = new AbortController(); + const signal = userSearchAbort.signal; + + document.getElementById('targetList').innerHTML = ` +
+
+
+
+
`; + try { + const resp = await fetch('/api/users?q=' + encodeURIComponent(term), { + headers: { 'Content-Type': 'application/json' }, + signal, + }); + let data = {}; + try { data = await resp.json(); } catch {} + if (signal.aborted) return; + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + + State.targets = data.items || []; + userSearchCache = { query: term, results: State.targets.slice() }; + // Selektion auf das neue Result-Set einschraenken — sonst bleibt der alte + // User im selectedTargetIds-Set haengen und verfaelscht alle Membership- + // Anfragen (status wird Partial/None weil der alte User nicht mehr matcht). + if (State.selectedTargetIds.size > 0) { + const visibleIds = new Set(State.targets.map(t => t.Id)); + const before = State.selectedTargetIds.size; + for (const id of [...State.selectedTargetIds]) { + if (!visibleIds.has(id)) State.selectedTargetIds.delete(id); + } + if (State.selectedTargetIds.size !== before) { + State.membershipCache.clear(); + } + } + renderTargets(); + onTargetSelectionChange(); + if (State.targets.length === 0) toast(`Keine Benutzer für "${term}" gefunden`, 'warn'); + } catch (e) { + if (e.name === 'AbortError') return; // neue Suche laeuft schon + toast(e.message, 'err', 'Benutzersuche fehlgeschlagen'); + renderTargets(); + } +} + +let loadTargetsInFlight = null; // welcher Mode laeuft gerade + +async function loadTargets(opts = {}) { + if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } + if (State.mode === 'user') { + const term = document.getElementById('targetSearch').value; + if (!term || term.length < 2) { + document.getElementById('targetSearch').focus(); + toast('Tippe mind. 2 Zeichen, um Benutzer zu suchen', 'warn'); + return; + } + return runUserSearch(term); + } + + const requestedMode = State.mode; + if (loadTargetsInFlight === requestedMode) return; // gleiche Anfrage laeuft bereits + loadTargetsInFlight = requestedMode; + + if (!opts.silent) setLoading('Lade ' + (requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Abteilungen') + '...'); + document.getElementById('targetList').innerHTML = ` +
+
+
+
+
+
`; + try { + const url = requestedMode === 'rpa' ? '/api/groups/rpa' : '/api/groups'; + const res = await api(url); + // User koennte zwischenzeitlich den Tab gewechselt haben — Resultat verwerfen + if (State.mode !== requestedMode) return; + State.targets = res.items || []; + State.loadedModes.add(requestedMode); + State.selectedTargetIds.clear(); + State.membershipCache.clear(); + // Cache fuer diesen Mode aktualisieren + State.modeCache.set(requestedMode, { + targets: State.targets.slice(), + selectedIds: new Set(), + expandedGroups: new Map(), + }); + renderTargets(); + onTargetSelectionChange(); + if (!opts.silent) toast(`${res.count} ${requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Abteilungen'} geladen`, 'ok'); + } catch (e) { + if (State.mode === requestedMode) { + toast(e.message, 'err', 'Laden fehlgeschlagen'); + renderTargets(); + } + } finally { + if (loadTargetsInFlight === requestedMode) loadTargetsInFlight = null; + if (!opts.silent) clearLoading(); + } +} + +function getFilteredTargets() { + const q = document.getElementById('targetSearch').value.toLowerCase().trim(); + if (!q || State.mode === 'user') return State.targets; + return State.targets.filter(t => + (t.DisplayName || '').toLowerCase().includes(q) || + (t.UserPrincipalName || '').toLowerCase().includes(q) + ); +} + +function renderTargets() { + const list = document.getElementById('targetList'); + const cnt = document.getElementById('targetCount'); + cnt.textContent = State.targets.length; + + const filtered = getFilteredTargets(); + + if (!State.connected) { + list.innerHTML = emptyState({ + title: 'Nicht verbunden', + text: 'Verbinde dich zuerst mit Microsoft Graph.', + }); + return; + } + + if (filtered.length === 0) { + if (State.mode === 'user') { + const term = document.getElementById('targetSearch').value; + list.innerHTML = emptyState({ + title: term && term.length >= 2 ? 'Keine Treffer' : 'Benutzer suchen', + text: term && term.length >= 2 + ? `Keine Benutzer für "${escapeHtml(term)}" gefunden.` + : 'Tippe mind. 2 Zeichen — Name, UPN oder E-Mail.', + }); + } else { + list.innerHTML = emptyState({ + title: State.targets.length === 0 ? 'Wird geladen...' : 'Keine Treffer', + text: State.targets.length === 0 + ? 'Wenn nichts kommt, klicke auf das Refresh-Icon oben rechts.' + : 'Filter zurücksetzen oder neu laden.', + }); + } + return; + } + + const isGroupMode = State.mode === 'dept' || State.mode === 'rpa'; + + list.innerHTML = filtered.map(t => { + const checked = State.selectedTargetIds.has(t.Id) ? 'checked' : ''; + const meta = t.UserPrincipalName + ? `
${escapeHtml(t.UserPrincipalName)}${t.Department ? ' · ' + escapeHtml(t.Department) : ''}
` + : ''; + const isSel = State.selectedTargetIds.has(t.Id) ? 'selected' : ''; + const isEmpty = isGroupMode && t.HasMembers === false ? 'empty' : ''; + const expanded = isGroupMode && State.expandedGroups.has(t.Id); + const expandBtn = isGroupMode + ? `` + : ''; + const expandedHtml = expanded ? renderExpandedMembers(t.Id, t.DisplayName) : ''; + // Externe Direkt-Links als dezente Icons rechts neben dem Namen. + // User → Entra-User. Gruppe → Entra-Gruppe + Mitglieder-Tab. + const linkIcons = t.UserPrincipalName + ? `${entraUserLink(t.Id, t.DisplayName)}${teamsChatLink(t.UserPrincipalName, t.DisplayName)}` + : `${entraGroupLink(t.Id, t.DisplayName)}${membersTabLink(t.Id, t.DisplayName)}`; + return `
+ + ${expandedHtml} +
`; + }).join(''); + + list.querySelectorAll('.target-row').forEach(row => { + row.addEventListener('change', e => { + const id = row.dataset.id; + if (e.target.checked) State.selectedTargetIds.add(id); + else State.selectedTargetIds.delete(id); + row.classList.toggle('selected', e.target.checked); + onTargetSelectionChange(); + }); + }); + + list.querySelectorAll('.row-expand').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + toggleGroupExpand(btn.dataset.groupId, btn.dataset.groupName); + }); + }); + + attachExpandedHandlers(list); +} + +function onTargetSelectionChange() { + State.membershipCache.clear(); + renderPinnedTargets(); + renderApps(); + renderExisting(); + updateMembershipForVisibleApps(); + renderSession(); + updateStepper(); +} + +function collectAllSelectedTargets() { + // Liefert [{ id, name, mode, meta }] ueber alle Modi hinweg. + // Aktueller Mode kommt aus State.targets/State.selectedTargetIds, + // andere Modi aus State.modeCache. + const all = []; + const seen = new Set(); + const push = (mode, targets, selectedIds) => { + const byId = new Map(targets.map(t => [t.Id, t])); + selectedIds.forEach(id => { + if (seen.has(id)) return; + seen.add(id); + const t = byId.get(id); + const name = t ? (t.DisplayName || id) : id; + const meta = t && t.UserPrincipalName ? t.UserPrincipalName : ''; + all.push({ id, name, mode, meta, hasMembers: t ? t.HasMembers : undefined }); + }); + }; + push(State.mode, State.targets, State.selectedTargetIds); + for (const m of ['dept','rpa','user']) { + if (m === State.mode) continue; + const c = State.modeCache.get(m); + if (c && c.selectedIds && c.selectedIds.size > 0) push(m, c.targets || [], c.selectedIds); + } + return all; +} + +function renderPinnedTargets() { + const wrap = document.getElementById('pinnedTargets'); + if (!wrap) return; + const items = collectAllSelectedTargets(); + if (items.length === 0) { + wrap.classList.add('hidden'); + wrap.innerHTML = ''; + return; + } + wrap.classList.remove('hidden'); + + const modeLabels = { dept: 'Abt', rpa: 'RPA', user: 'User' }; + const chips = items.map(it => { + const isEmpty = it.hasMembers === false ? 'pin-empty' : ''; + const title = `${it.name}${it.meta ? ' · ' + it.meta : ''} (${modeLabels[it.mode] || it.mode}) — Klick: zur Zeile springen, X: entfernen`; + return ` + ${escapeHtml(modeLabels[it.mode] || it.mode)} + ${escapeHtml(it.name)} + + `; + }).join(''); + + wrap.innerHTML = ` +
+ Aktuelle Auswahl + ${items.length} + +
+
${chips}
+ `; + + wrap.querySelectorAll('.pin-remove').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + removePinned(btn.dataset.id, btn.dataset.mode); + }); + }); + wrap.querySelectorAll('.pin-chip').forEach(chip => { + chip.addEventListener('click', e => { + if (e.target.closest('.pin-remove')) return; + jumpToPinned(chip.dataset.id, chip.dataset.mode); + }); + }); + wrap.querySelector('.pin-clear-all').addEventListener('click', clearAllPinned); +} + +function removePinned(id, mode) { + if (mode === State.mode) { + State.selectedTargetIds.delete(id); + } else { + const c = State.modeCache.get(mode); + if (c && c.selectedIds) c.selectedIds.delete(id); + } + renderTargets(); + onTargetSelectionChange(); +} + +function clearAllPinned() { + State.selectedTargetIds.clear(); + for (const m of ['dept','rpa','user']) { + const c = State.modeCache.get(m); + if (c && c.selectedIds) c.selectedIds.clear(); + } + renderTargets(); + onTargetSelectionChange(); +} + +function jumpToPinned(id, mode) { + if (mode !== State.mode) { + const seg = document.querySelector(`.seg[data-mode="${mode}"]`); + if (seg) seg.click(); + // Nach Mode-Wechsel kurz warten, bis renderTargets durchgelaufen ist + setTimeout(() => focusTargetRow(id), 50); + } else { + focusTargetRow(id); + } +} + +function focusTargetRow(id) { + const row = document.querySelector(`#targetList .target-row[data-id="${CSS.escape(id)}"]`); + if (!row) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('flash'); + setTimeout(() => row.classList.remove('flash'), 1200); +} + +// ============================================================= +// Apps +// ============================================================= + +document.getElementById('btnReloadApps').addEventListener('click', () => loadApps(true)); + +document.getElementById('appSearch').addEventListener('input', e => { + State.appFilter.search = e.target.value.toLowerCase().trim(); + renderApps(); +}); +document.getElementById('appFilter').addEventListener('change', e => { + State.appFilter.preset = e.target.value; + renderApps(); +}); +document.getElementById('appCategoryFilter').addEventListener('change', e => { + State.appFilter.category = e.target.value; + renderApps(); +}); +document.getElementById('chkOnlyMember').addEventListener('change', e => { + State.appFilter.onlyMember = e.target.checked; + renderApps(); +}); +document.getElementById('chkHideNative').addEventListener('change', e => { + State.appFilter.hideNative = e.target.checked; + renderApps(); +}); + +// Date-Range Filter (Zuletzt geaendert) — flatpickr ersetzt den nativen +// date-input Popup. flatpickr garantiert konsistentes Look-and-Feel und +// vermeidet den Chromium-Quirk mit schwarzem Initial-Render des Popups. +(function setupDateModFilter() { + const inFrom = document.getElementById('dateModFrom'); + const inTo = document.getElementById('dateModTo'); + const btnX = document.getElementById('dateModClear'); + if (!inFrom || !inTo || !btnX) return; + + // Native type="date" auf type="text" umstellen — sonst klatscht der + // Browser sein Picker-UI ueber das von flatpickr drueber. + inFrom.type = 'text'; + inTo.type = 'text'; + inFrom.placeholder = 'tt.mm.jjjj'; + inTo.placeholder = 'tt.mm.jjjj'; + + const syncClearVisibility = () => { + btnX.hidden = !(State.appFilter.modFrom || State.appFilter.modTo); + }; + + const commonOpts = { + dateFormat: 'Y-m-d', // intern speichern wir ISO (matched Backend-Format) + altInput: true, // zweites Input fuer die Anzeige in DE-Format + altFormat: 'd.m.Y', // angezeigt: 18.05.2026 + allowInput: true, + locale: { + weekdays: { shorthand: ['So','Mo','Di','Mi','Do','Fr','Sa'], longhand: ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'] }, + months: { + shorthand: ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'], + longhand: ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'], + }, + firstDayOfWeek: 1, + rangeSeparator: ' bis ', + }, + }; + + const fpFrom = window.flatpickr(inFrom, Object.assign({}, commonOpts, { + onChange: (sel, str) => { + State.appFilter.modFrom = str || ''; + // "Bis" darf nicht vor "Von" liegen + if (fpTo && sel[0]) fpTo.set('minDate', sel[0]); + syncClearVisibility(); + renderApps(); + }, + })); + const fpTo = window.flatpickr(inTo, Object.assign({}, commonOpts, { + onChange: (sel, str) => { + State.appFilter.modTo = str || ''; + if (fpFrom && sel[0]) fpFrom.set('maxDate', sel[0]); + syncClearVisibility(); + renderApps(); + }, + })); + + btnX.addEventListener('click', () => { + fpFrom.clear(); + fpTo.clear(); + fpFrom.set('maxDate', null); + fpTo.set('minDate', null); + State.appFilter.modFrom = ''; + State.appFilter.modTo = ''; + syncClearVisibility(); + renderApps(); + }); +})(); + +async function loadApps(force, opts = {}) { + if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } + if (!opts.silent) setLoading(force ? 'Lade Apps neu (kann Sekunden dauern)...' : 'Lade Apps aus Intune...'); + document.getElementById('appList').innerHTML = ` +
+
+
+
+
+
+
`; + try { + const url = force ? '/api/apps?refresh=true' : '/api/apps'; + const res = await api(url); + State.apps = res.items || []; + State.appsLoaded = true; + if (force) { + State.appDetails.clear(); + State.expandedApps.clear(); + } + rebuildCategoryFilterOptions(); + renderApps(); + updateMembershipForVisibleApps(); + if (!opts.silent) toast(`${res.count} Apps ${res.cached ? 'aus Cache' : 'geladen'}`, 'ok'); + // Kategorien NACH dem App-Laden im Hintergrund holen (separater $batch im + // Backend) — blockiert das App-Laden nicht. Fuellt danach den Kategorie-Filter. + loadAppCategoriesInBackground(); + } catch (e) { + toast(e.message, 'err', 'Apps laden fehlgeschlagen'); + renderApps(); + } finally { + if (!opts.silent) clearLoading(); + } +} + +async function loadAppCategoriesInBackground() { + // Holt die appId->Kategorien-Map separat (Backend nutzt $batch) und merged + // sie in die geladene App-Liste, dann Filter neu aufbauen. Fehler werden + // still verschluckt — die App-Liste funktioniert auch ohne Kategorien. + try { + const res = await api('/api/apps/categories', { timeoutMs: 300000 }); + const map = res && res.map ? res.map : {}; + let any = false; + for (const a of State.apps) { + if (Object.prototype.hasOwnProperty.call(map, a.AppId)) { + a.Categories = map[a.AppId] || []; + any = true; + } + } + if (any) { + rebuildCategoryFilterOptions(); + // Nur neu rendern wenn ein Kategorie-Filter aktiv ist (sonst unnoetig) + if (State.appFilter.category) renderApps(); + } + } catch (e) { + // bewusst still — Kategorien sind optional fuer den Filter + console.warn('Kategorien-Hintergrundladen fehlgeschlagen:', e.message); + } +} + +function getFilteredApps() { + let list = State.apps.slice(); + const f = State.appFilter; + if (f.search) list = list.filter(a => (a.AppName || '').toLowerCase().includes(f.search)); + switch (f.preset) { + case 'none': list = list.filter(a => a.AvailableCount === 0 && a.RequiredCount === 0); break; + case 'available': list = list.filter(a => a.AvailableCount > 0 && a.RequiredCount === 0); break; + case 'required': list = list.filter(a => a.RequiredCount > 0 && a.AvailableCount === 0); break; + case 'multipleAvail':list = list.filter(a => a.AvailableCount > 1); break; + case 'multipleReq': list = list.filter(a => a.RequiredCount > 1); break; + case 'allUsers': list = list.filter(a => + a.AvailableGroups.some(g => g.GroupId === 'ALL_USERS') || + a.RequiredGroups.some(g => g.GroupId === 'ALL_USERS')); break; + case 'allDevices': list = list.filter(a => + a.AvailableGroups.some(g => g.GroupId === 'ALL_DEVICES') || + a.RequiredGroups.some(g => g.GroupId === 'ALL_DEVICES')); break; + case 'srcIntune': list = list.filter(a => a.Source === 'Intune'); break; + default: + // Generisch "srcVendor:" — vendor.displayName aus Settings-Map ziehen + if (typeof f.preset === 'string' && f.preset.startsWith('srcVendor:')) { + const vid = f.preset.substring('srcVendor:'.length); + const v = (SettingsState.vendorsById || {})[vid]; + if (v) list = list.filter(a => a.Source === v.displayName); + } + break; + } + // Kategorie-Filter: leerer Wert = alle. Spezialwert '__none__' = Apps ohne + // zugewiesene Kategorie. Sonst exakter Match auf einen Kategorie-Namen. + if (f.category) { + if (f.category === '__none__') { + list = list.filter(a => !Array.isArray(a.Categories) || a.Categories.length === 0); + } else { + list = list.filter(a => Array.isArray(a.Categories) && a.Categories.includes(f.category)); + } + } + if (f.hideNative) { + list = list.filter(a => + !a.AvailableGroups.some(g => g.IsNative) && + !a.RequiredGroups.some(g => g.IsNative)); + } + if (f.onlyMember) { + list = list.filter(a => { + const all = [...a.AvailableGroups, ...a.RequiredGroups]; + return all.some(g => { + const m = getCachedMembership(g.GroupId); + return m && (m.status === 'Full' || m.status === 'Partial'); + }); + }); + } + // Zeitraum-Filter auf LastModifiedDateTime — von/bis inklusive + if (f.modFrom || f.modTo) { + const fromMs = f.modFrom ? Date.parse(f.modFrom + 'T00:00:00') : null; + // "Bis" inklusiv: bis Ende des Tages (23:59:59.999) + const toMs = f.modTo ? Date.parse(f.modTo + 'T23:59:59.999') : null; + list = list.filter(a => { + if (!a.LastModifiedDateTime) return false; + const t = Date.parse(a.LastModifiedDateTime); + if (isNaN(t)) return false; + if (fromMs !== null && t < fromMs) return false; + if (toMs !== null && t > toMs) return false; + return true; + }); + } + return list; +} + +function renderApps() { + const list = document.getElementById('appList'); + const cnt = document.getElementById('appCount'); + const total = State.apps.length; + const filtered = getFilteredApps(); + cnt.textContent = total === filtered.length ? total : `${filtered.length}/${total}`; + + if (!State.connected) { + list.innerHTML = emptyState({ + title: 'Nicht verbunden', + text: 'Verbinde dich zuerst mit Microsoft Graph.', + }); + return; + } + + if (State.apps.length === 0) { + list.innerHTML = emptyState({ + title: 'Apps werden geladen...', + text: 'Falls nicht, klicke auf das Refresh-Icon oben rechts.', + }); + return; + } + + if (filtered.length === 0) { + list.innerHTML = emptyState({ + title: 'Keine Treffer', + text: 'Filter oder Suche zurücksetzen.', + }); + return; + } + + list.innerHTML = filtered.map(app => renderAppRow(app)).join(''); + list.querySelectorAll('.app-row-wrap').forEach(wrap => attachAppRowEvents(wrap)); +} + +function renderAppRow(app) { + const sessionPills = State.session.filter(s => s.appId === app.AppId); + + // Aggregierter Member-Status fuer den Indikator vor App-Name + let isFullMember = false, isPartialMember = false; + if (State.selectedTargetIds.size > 0) { + for (const g of [...app.AvailableGroups, ...app.RequiredGroups]) { + if (g.IsNative) continue; + const m = getCachedMembership(g.GroupId); + if (!m) continue; + if (m.status === 'Full') isFullMember = true; + if (m.status === 'Partial') isPartialMember = true; + } + } + const statusDot = isFullMember + ? `✓` + : (isPartialMember ? `◐` : ''); + + const queuedCount = sessionPills.length; + const queuedBadge = queuedCount > 0 ? `${queuedCount}` : ''; + + // Sammel-Button pro Typ. Klick: + // - 1 zuweisbare Gruppe → direkt zu den geplanten Zuweisungen hinzufuegen + // - mehrere → Popover mit Auswahl + // - nur native → disabled + const renderTypeButton = (groups, type) => { + if (groups.length === 0) return ''; + const assignable = groups.filter(g => !g.IsNative); + const queuedForType = sessionPills.filter(s => s.type === type).length; + + let memberCls = ''; + if (State.selectedTargetIds.size > 0) { + let full = false, partial = false; + for (const g of assignable) { + const m = getCachedMembership(g.GroupId); + if (!m) continue; + if (m.status === 'Full') full = true; + if (m.status === 'Partial') partial = true; + } + if (full) memberCls = 'member-full'; + else if (partial) memberCls = 'member-partial'; + } + + const isNativeOnly = assignable.length === 0; + const hasQueued = queuedForType > 0; + const cls = ['type-btn', `type-${type.toLowerCase()}`, + isNativeOnly ? 'native-only' : '', + hasQueued ? 'has-queued' : '', + memberCls].filter(Boolean).join(' '); + + const countSuffix = assignable.length > 1 ? `${assignable.length}` : ''; + const queuedSuffix = hasQueued ? `${queuedForType}` : ''; + + let title; + if (isNativeOnly) { + title = `Nur native Zuweisung (${groups.map(g => g.GroupName).join(', ')}) — nicht änderbar`; + } else if (groups.length === 1) { + title = `${type}: ${assignable[0].GroupName}\nKlick: zu geplanten Zuweisungen hinzufügen`; + } else { + title = `${type} (${groups.length} Gruppen) — Klick zum Auswählen:\n • ` + assignable.map(g => g.GroupName).join('\n • '); + } + + return ``; + }; + + const availBtn = renderTypeButton(app.AvailableGroups, 'Available'); + const reqBtn = renderTypeButton(app.RequiredGroups, 'Required'); + const createBtn = ``; + + const hasNoAssignments = app.AvailableGroups.length === 0 && app.RequiredGroups.length === 0; + // Direkt-Link in das Intune Admin Center fuer diese App (dezentes Icon, NICHT der Name selbst) + const intuneIcon = intuneAppLink(app.AppId, app.AppName); + // Source-Badge: pro Vendor (aus Settings) ODER Intune-Default. + const vendor = (SettingsState.vendorsByDisplayName || {})[app.Source]; + const srcLogo = vendor ? `/assets/${encodeURIComponent(vendor.logoFile || 'application.png')}` : '/assets/intune.png'; + const srcUrl = vendor ? (vendor.portalUrl || 'https://intune.microsoft.com/') : 'https://intune.microsoft.com/'; + const srcTitle = vendor ? `Bereitgestellt durch ${vendor.displayName} — Portal öffnen` : 'Native Intune-App — Intune öffnen'; + const srcClass = vendor ? `src-${escapeHtml(vendor.id)}` : 'src-intune'; + // Fallback: wenn das Vendor-Logo nicht da ist, zeigen wir den ersten Buchstaben des Vendor-Namens. + const srcFallback = vendor ? `this.replaceWith(Object.assign(document.createElement('span'), { className: 'app-source app-source-letter ${srcClass}', textContent: '${escapeHtml(vendor.displayName[0] || '?')}' }))` : ''; + const srcBadge = `${srcTitle}`; + // Vendor-managed Apps koennen NUR im jeweiligen Portal geloescht/umbenannt + // werden. Wir zeigen ein passives Lock-Icon mit Erklaerung statt Trash/Pencil. + const deleteLocked = !!(vendor && vendor.block && vendor.block.delete); + const renameLocked = !!(vendor && vendor.block && vendor.block.rename); + const deleteBtn = deleteLocked + ? `${LOCK_ICON}` + : ``; + const expanded = State.expandedApps.has(app.AppId); + const detailHtml = expanded ? renderAppDetailPanel(app.AppId) : ''; + return `
+
+
${statusDot}
+
+
+ ${escapeHtml(app.AppName)} + ${queuedBadge} + + ${renameLocked + ? `${LOCK_ICON}` + : ``} + ${intuneIcon} + +
+ ${app.Publisher ? `
${escapeHtml(app.Publisher)}
` : ''} +
+
${srcBadge}
+
${escapeHtml(app.AppType)}
+
${app.Version ? `${escapeHtml(app.Version)}` : '—'}
+
${availBtn || '—'}
+
${reqBtn || '—'}
+
${createBtn}
+
${deleteBtn}
+
+ ${detailHtml} +
`; +} + +function renderAssignmentsSection(app) { + // Zwei Listen: Available + Required. Native (All Users / All Devices) werden + // mitgezeigt aber als nicht-loeschbar markiert. + const renderGroupList = (groups, type) => { + if (!groups || groups.length === 0) { + return `
Keine ${type === 'available' ? 'Available' : 'Required'}-Zuweisungen.
`; + } + return `
    ${groups.map(g => { + const isNative = g.IsNative; + const memb = getCachedMembership(g.GroupId); + let mLabel = ''; + if (memb && !isNative) { + if (memb.status === 'Full') mLabel = `alle Mitglied`; + else if (memb.status === 'Partial') mLabel = `${memb.matched}/${memb.total} Mitglied`; + } + const linkIcons = isNative + ? `native` + : `${entraGroupLink(g.GroupId, g.GroupName)}${membersTabLink(g.GroupId, g.GroupName)}`; + const delBtn = isNative + ? '' + : ``; + return `
  • + ${escapeHtml(g.GroupName)} + ${mLabel} + ${linkIcons}${delBtn} +
  • `; + }).join('')}
`; + }; + + const avail = renderGroupList(app.AvailableGroups, 'available'); + const req = renderGroupList(app.RequiredGroups, 'required'); + + return `
+
Zuweisungen
+
+
+
Available${app.AvailableGroups.length}
+ ${avail} +
+
+
Required${app.RequiredGroups.length}
+ ${req} +
+
+
`; +} + +function renderAppDetailPanel(appId) { + const entry = State.appDetails.get(appId); + if (!entry || entry.loading) { + return `
+
Lade Details aus Intune...
+
`; + } + if (entry.error) { + return `
+
Konnte Details nicht laden: ${escapeHtml(entry.error)}
+
`; + } + const d = entry.details || {}; + + const row = (label, value, opts = {}) => { + if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) return ''; + let v = value; + if (Array.isArray(v)) { + // Defensiv gegen Backend-Bugs: wenn Items Objekte sind, displayName/name + // extrahieren statt "[object Object]" zu rendern. + v = v.map(item => { + if (item === null || item === undefined) return ''; + if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') return String(item); + if (typeof item === 'object') return item.displayName || item.DisplayName || item.name || item.Name || item.id || ''; + return String(item); + }).filter(s => s !== '').join(', '); + if (!v) return ''; + } + if (typeof v === 'boolean') v = v ? 'Ja' : 'Nein'; + // Letzte Verteidigung: wenn v immer noch ein Objekt ist (single value). + if (v && typeof v === 'object') { + v = v.displayName || v.DisplayName || v.name || v.Name || v.id || JSON.stringify(v); + } + const cls = opts.mono ? 'app-detail-val mono' : 'app-detail-val'; + const copy = opts.copy ? `` : ''; + return `
+
${escapeHtml(label)}
+
${escapeHtml(String(v))}${copy}
+
`; + }; + + const fmtDate = (s) => { + if (!s) return null; + // Defensiv: new Date() wirft KEINE Exception, gibt aber Invalid Date + // zurueck — also explizit pruefen. Auch Legacy "/Date(ms)/" akzeptieren. + let d = new Date(s); + if (isNaN(d.getTime())) { + const m = String(s).match(/\/Date\((-?\d+)/); + if (m) d = new Date(Number(m[1])); + } + if (isNaN(d.getTime())) return null; + return d.toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + }; + + const sections = []; + + // Zuweisungen: zuerst — direkt sichtbar nach dem Aufklappen + const app = State.apps.find(a => a.AppId === appId); + if (app) { + sections.push(renderAssignmentsSection(app)); + } + + // Allgemein + const general = [ + row('Hersteller', d.Publisher), + row('Entwickler', d.Developer), + row('Owner', d.Owner), + row('Version', d.DisplayVersion || d.Version || d.ProductVersion), + row('Typ', d.Type), + row('Architekturen', d.Architectures), + row('Min. OS', d.MinimumOS), + row('Kategorien', (Array.isArray(d.Categories) && d.Categories.length > 0) ? d.Categories : 'Default'), + row('Install-Kontext', d.InstallExperience), + row('Featured', d.IsFeatured), + ].filter(Boolean).join(''); + if (general) sections.push(`
Allgemein
${general}
`); + + // Beschreibung + if (d.Description || d.Notes) { + let desc = ''; + if (d.Description) desc += `
${renderMarkdown(d.Description)}
`; + if (d.Notes) desc += `
Notizen: ${renderMarkdown(d.Notes)}
`; + sections.push(`
Beschreibung
${desc}
`); + } + + // Install / Uninstall + const installRows = [ + row('Datei', d.FileName, { mono: true }), + row('Setup-Pfad', d.SetupFilePath, { mono: true }), + row('Install-Befehl', d.InstallCommandLine, { mono: true, copy: true }), + row('Uninstall-Befehl', d.UninstallCommandLine, { mono: true, copy: true }), + row('Command-Line', d.CommandLine, { mono: true, copy: true }), + ].filter(Boolean).join(''); + if (installRows) sections.push(`
Installation
${installRows}
`); + + // MSI + if (d.Msi) { + const msiRows = [ + row('Product Code', d.Msi.ProductCode, { mono: true, copy: true }), + row('Upgrade Code', d.Msi.UpgradeCode, { mono: true, copy: true }), + row('Product Version', d.Msi.ProductVersion), + row('Publisher (MSI)', d.Msi.Publisher), + row('Package-Type', d.Msi.PackageType), + row('Reboot nötig', d.Msi.RequiresReboot), + ].filter(Boolean).join(''); + if (msiRows) sections.push(`
MSI
${msiRows}
`); + } + + // Detection-Rules — wide weil Registry-Pfade lang werden koennen + if (d.DetectionRules && d.DetectionRules.length) { + sections.push(`
+
Detection
+
    ${d.DetectionRules.map(r => `
  • ${escapeHtml(r)}
  • `).join('')}
+
`); + } + + // Return-Codes + if (d.ReturnCodes && d.ReturnCodes.length) { + const rows = d.ReturnCodes.map(rc => `
  • ${escapeHtml(String(rc.Code))} · ${escapeHtml(rc.Type)}
  • `).join(''); + sections.push(`
    Return-Codes
      ${rows}
    `); + } + + // Meta + const metaRows = [ + row('Erstellt', fmtDate(d.CreatedDateTime)), + row('Geändert', fmtDate(d.LastModifiedDateTime)), + d.InformationUrl ? `` : '', + d.PrivacyInformationUrl ? `` : '', + ].filter(Boolean).join(''); + if (metaRows) sections.push(`
    Meta
    ${metaRows}
    `); + + // Installationen — Stats aus installSummary (Erfolg/Fehler je User/Device) + if (d.InstallSummary) { + sections.push(renderInstallStatsSection(d.InstallSummary)); + } + + // Dependencies — Voraussetzungen dieser App + if (d.Dependencies && d.Dependencies.length) { + sections.push(renderRelationshipFlow({ + title: 'Abhängigkeiten', + subtitle: 'Apps, die installiert sein müssen, bevor diese App installiert werden kann.', + centerApp: { AppId: appId, AppName: d.DisplayName || 'Diese App' }, + related: d.Dependencies, + direction: 'incoming', // Pfeile von dep -> diese App + kindLabelFn: (r) => r.DependencyType === 'autoInstall' ? 'autoInstall' : 'detect', + explanationFn: (center, r) => { + const c = `${escapeHtml(center.AppName)}`; + const t = `${escapeHtml(r.TargetDisplayName || r.TargetId)}`; + if (r.DependencyType === 'autoInstall') { + return `Wenn ${c} einem Gerät zugewiesen wird, installiert Intune zuvor automatisch ${t} mit.`; + } + return `${c} erwartet, dass ${t} bereits installiert ist. Intune installiert es nicht automatisch — manuelle Bereitstellung nötig (Modus „detect“).`; + }, + })); + } + + // Supersedence — was diese App ersetzt/aktualisiert + if (d.Supersedence && d.Supersedence.length) { + sections.push(renderRelationshipFlow({ + title: 'Supersedence', + subtitle: 'Ältere Apps, die durch diese App ersetzt oder aktualisiert werden.', + centerApp: { AppId: appId, AppName: d.DisplayName || 'Diese App' }, + related: d.Supersedence, + direction: 'outgoing', // Pfeile diese App -> ersetzte App + kindLabelFn: (r) => r.SupersedenceType === 'replace' ? 'replace' : 'update', + explanationFn: (center, r) => { + const c = `${escapeHtml(center.AppName)}`; + const t = `${escapeHtml(r.TargetDisplayName || r.TargetId)}`; + if (r.SupersedenceType === 'replace') { + return `Bei der Installation deinstalliert ${c} eine vorhandene Version von ${t} und ersetzt sie vollständig (Modus „replace“).`; + } + return `${c} aktualisiert eine vorhandene Installation von ${t} (Modus „update“) — Benutzerdaten und Konfiguration bleiben erhalten.`; + }, + })); + } + + // Setup-Datei aktualisieren — Phase 1: nur win32LobApp (.intunewin), + // und nur fuer NICHT vendor-managed Apps (PMPC/Robopack ausgeschlossen). + if (app && app.AppTypeRaw === '#microsoft.graph.win32LobApp' + && !(SettingsState.vendorsByDisplayName || {})[app.Source]) { + sections.push(`
    +
    Setup-Datei
    +
    + +
    Lädt eine neue .intunewin hoch und aktiviert sie als neue Version. Betrifft alle zugewiesenen Geräte beim nächsten Sync.
    +
    +
    `); + } + + if (sections.length === 0) { + return `
    Keine zusätzlichen Details verfügbar.
    `; + } + + return `
    ${sections.join('')}
    `; +} + +// ============================================================= +// App-Detail: Installations-Statistik + Relationship-Flow-Viz +// ============================================================= + +function renderInstallStatsSection(s) { + // Berechnet einen kleinen Erfolgs-Quotient (Installed / (Installed + Failed)) + // pro Achse. Wenn keine Vorgaenge: zeige nur Counter ohne %-Wert. + const pct = (ok, fail) => { + const total = ok + fail; + if (total === 0) return null; + return Math.round((ok / total) * 100); + }; + const stat = (label, value, kind) => ` +
    +
    ${value}
    +
    ${escapeHtml(label)}
    +
    `; + + const dev = { + ok: s.InstalledDeviceCount, + fail: s.FailedDeviceCount, + pend: s.PendingInstallDeviceCount, + not: s.NotInstalledDeviceCount, + na: s.NotApplicableDeviceCount, + }; + const usr = { + ok: s.InstalledUserCount, + fail: s.FailedUserCount, + pend: s.PendingInstallUserCount, + not: s.NotInstalledUserCount, + na: s.NotApplicableUserCount, + }; + const devPct = pct(dev.ok, dev.fail); + const usrPct = pct(usr.ok, usr.fail); + + return `
    +
    Installationen
    +
    +
    +
    + Geräte + ${devPct !== null ? `${devPct}% erfolgreich` : ''} +
    +
    + ${stat('Installiert', dev.ok, 'ok')} + ${stat('Fehler', dev.fail, 'err')} + ${stat('Ausstehend', dev.pend, 'pend')} + ${stat('Nicht inst.', dev.not, 'not')} + ${stat('N/A', dev.na, 'na')} +
    +
    +
    +
    + Benutzer + ${usrPct !== null ? `${usrPct}% erfolgreich` : ''} +
    +
    + ${stat('Installiert', usr.ok, 'ok')} + ${stat('Fehler', usr.fail, 'err')} + ${stat('Ausstehend', usr.pend, 'pend')} + ${stat('Nicht inst.', usr.not, 'not')} + ${stat('N/A', usr.na, 'na')} +
    +
    +
    +
    `; +} + +// Mini-SVG-Flow: zentraler App-Knoten + verbundene Target-Knoten. +// direction: 'incoming' = Pfeile target->center (Dependencies) +// 'outgoing' = Pfeile center->target (Supersedence) +// Verbindungen sind ORTHOGONAL (gerade Linien mit rechtwinkligen Knicken), +// keine Bezier-Kurven. Pro Relation wird zusaetzlich ein Klartext-Satz +// generiert (explanationFn), der direkt unter dem Diagramm steht. +function renderRelationshipFlow(opts) { + const { title, subtitle, centerApp, related, direction, kindLabelFn, explanationFn } = opts; + if (!related || related.length === 0) return ''; + + const flowId = 'flow-' + Math.random().toString(36).slice(2, 9); + // Layout-Geometrie — etwas grosszuegiger als vorher fuer "premium" Wirkung + const NODE_W = 230; + const NODE_H = 60; + const ROW_H = 84; // Vertikaler Abstand zwischen Target-Nodes + const COL_GAP = 170; // Horizontaler Platz fuer die Pfeil-Linie + const PAD = 16; + const count = related.length; + + // Hoehe so dass Center und Target-Stack vertikal gleich hoch sind + const stackH = (count - 1) * ROW_H + NODE_H; + const svgH = Math.max(stackH, NODE_H) + PAD * 2; + const svgW = NODE_W * 2 + COL_GAP + PAD * 2; + + // Center vertikal mittig + const centerY = (svgH - NODE_H) / 2; + // Center ist je nach Richtung links oder rechts + const centerOnLeft = (direction === 'outgoing'); + const centerX = centerOnLeft ? PAD : (svgW - PAD - NODE_W); + const targetX = centerOnLeft ? (svgW - PAD - NODE_W) : PAD; + + // Target-Y so verteilen dass Stack vertikal zentriert ist + const stackTop = (svgH - stackH) / 2; + const targetY = (i) => stackTop + i * ROW_H; + + const knownApp = (id) => State.apps.find(a => a.AppId === id); + + // Node-Renderer als HTML-Foreign-Object (Text-Wrapping einfacher als in SVG) + const nodeHtml = (a, isCenter) => { + const known = !!knownApp(a.AppId || a.TargetId); + const id = a.AppId || a.TargetId || ''; + const name = escapeHtml(a.AppName || a.TargetDisplayName || id); + const meta = isCenter ? '' : [a.TargetPublisher, a.TargetDisplayVersion].filter(Boolean).map(escapeHtml).join(' · '); + const cls = `flow-node ${isCenter ? 'flow-node-center' : 'flow-node-target'} ${known && !isCenter ? 'flow-node-known' : ''}`; + return `
    +
    ${name}
    + ${meta ? `
    ${meta}
    ` : ''} +
    `; + }; + + // Orthogonale Pfade: horizontal → vertikal → horizontal (Elbow). + // Wenn from-Y == to-Y degeneriert das automatisch zu einer geraden Linie. + const arrows = related.map((r, i) => { + const ty = targetY(i); + const cyMid = centerY + NODE_H / 2; + const tyMid = ty + NODE_H / 2; + const fromX = centerOnLeft ? (centerX + NODE_W) : (targetX + NODE_W); + const fromY = centerOnLeft ? cyMid : tyMid; + const toX = centerOnLeft ? targetX : centerX; + const toY = centerOnLeft ? tyMid : cyMid; + const midX = (fromX + toX) / 2; + const d = `M ${fromX} ${fromY} L ${midX} ${fromY} L ${midX} ${toY} L ${toX} ${toY}`; + const labelX = midX; + const labelY = (fromY + toY) / 2 - 6; + const kind = kindLabelFn ? kindLabelFn(r) : ''; + return ` + ${kind ? `${escapeHtml(kind)}` : ''}`; + }).join(''); + + // Target-Nodes (HTML overlay via foreignObject) + const targetNodesSvg = related.map((r, i) => { + const y = targetY(i); + return ` +
    ${nodeHtml(r, false)}
    +
    `; + }).join(''); + + // Klartext-Erklaerungen pro Relation + const explanations = explanationFn ? related.map(r => explanationFn(centerApp, r)).filter(Boolean) : []; + const explanationHtml = explanations.length ? ` +
    +
    Was bedeutet das?
    +
      ${explanations.map(e => `
    • ${e}
    • `).join('')}
    +
    ` : ''; + + return `
    +
    ${escapeHtml(title)}
    +
    ${escapeHtml(subtitle)}
    +
    + + + + + + + ${arrows} + +
    ${nodeHtml(centerApp, true)}
    +
    + ${targetNodesSvg} +
    +
    + ${explanationHtml} +
    `; +} + +// Klick auf einen Flow-Node: zur Ziel-App scrollen + expanden +function attachFlowNodeHandlers(wrap) { + wrap.querySelectorAll('[data-jump-app]').forEach(el => { + el.addEventListener('click', () => jumpToAppById(el.dataset.jumpApp)); + el.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); jumpToAppById(el.dataset.jumpApp); } + }); + }); +} + +async function jumpToAppById(appId) { + if (!appId) return; + // Suchfeld leeren damit die App garantiert sichtbar ist + const search = document.getElementById('appSearch'); + if (search && search.value) { search.value = ''; State.appFilter.search = ''; } + + // WICHTIG: Wenn die App noch nicht expanded ist, muessen wir den DETAILS- + // FETCH triggern. Frueher haben wir nur expandedApps.add() gemacht — ohne + // Fetch — und der Spinner drehte ewig. Wenn der User dann die Zeile manuell + // klickt, sieht toggleAppExpand "schon offen" und KLAPPT WIEDER ZU. + const alreadyOpen = State.expandedApps.has(appId); + const wasCached = State.appDetails.has(appId); + + if (!alreadyOpen) { + State.expandedApps.add(appId); + } + renderApps(); + + // Fetch nur ausloesen wenn keine validen Daten im Cache liegen + const cached = State.appDetails.get(appId); + if (!cached || (!cached.details && !cached.loading)) { + State.appDetails.set(appId, { details: null, loading: true, error: null }); + renderApps(); + try { + const res = await api(`/api/apps/${encodeURIComponent(appId)}/details`); + State.appDetails.set(appId, { details: res, loading: false, error: null }); + } catch (e) { + State.appDetails.set(appId, { details: null, loading: false, error: e.message }); + } + if (State.expandedApps.has(appId)) renderApps(); + } + + // Im naechsten Frame: in den View scrollen + requestAnimationFrame(() => { + const wrap = document.querySelector(`.app-row-wrap[data-app-id="${CSS.escape(appId)}"]`); + if (wrap) { + wrap.scrollIntoView({ behavior: 'smooth', block: 'center' }); + wrap.classList.add('app-row-wrap--flash'); + setTimeout(() => wrap.classList.remove('app-row-wrap--flash'), 1200); + } else { + toast('Ziel-App nicht in der aktuell geladenen Liste', 'warn'); + } + }); +} + +async function toggleAppExpand(appId) { + if (State.expandedApps.has(appId)) { + State.expandedApps.delete(appId); + renderApps(); + return; + } + State.expandedApps.add(appId); + // Bereits geladene oder fertig im Cache → kein Reload + const cached = State.appDetails.get(appId); + if (cached && !cached.loading && !cached.error && cached.details) { + renderApps(); + return; + } + State.appDetails.set(appId, { details: null, loading: true, error: null }); + renderApps(); + try { + // 45s Timeout — falls Backend haengt, bricht der Spinner ab und der User + // bekommt eine Fehlermeldung statt ewig zu warten. + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 45000); + const opts = { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: ctrl.signal }; + const r = await fetch(`/api/apps/${encodeURIComponent(appId)}/details`, opts); + clearTimeout(timer); + let res = {}; + try { res = await r.json(); } catch {} + if (!r.ok) throw new Error(res.error || `HTTP ${r.status}`); + State.appDetails.set(appId, { details: res, loading: false, error: null }); + } catch (e) { + const msg = e.name === 'AbortError' ? 'Timeout (45s) — Graph-API antwortet nicht' : e.message; + State.appDetails.set(appId, { details: null, loading: false, error: msg }); + } + if (State.expandedApps.has(appId)) renderApps(); +} + +function attachAppRowEvents(wrap) { + wrap.querySelectorAll('.type-btn[data-type]').forEach(btn => { + btn.addEventListener('click', e => { + if (btn.disabled) return; + e.stopPropagation(); + onTypeButtonClick(btn); + }); + }); + wrap.querySelectorAll('[data-create-group]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + openCreateGroupModal(btn.dataset.appId, btn.dataset.appName); + }); + }); + wrap.querySelectorAll('[data-delete-app]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + deleteAppWithConfirm(btn.dataset.appId, btn.dataset.appName); + }); + }); + wrap.querySelectorAll('[data-rename-app]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + openRenameAppModal(btn.dataset.appId, btn.dataset.appName); + }); + }); + wrap.querySelectorAll('[data-update-content]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + openUpdateContentModal(btn.dataset.appId, btn.dataset.appName, btn.dataset.appType, btn.dataset.appVersion); + }); + }); + // Im Detail-Panel (Zuweisungen-Sektion) sitzen die Delete-Assignment-Buttons + // und externe Link-Icons — hier auch verkabeln, damit Klicks weder zur + // Session togglen noch das Detail-Panel zuklappen. + wrap.querySelectorAll('[data-remove-assign]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + removeAssignmentWithConfirm({ + appId: btn.dataset.appId, + appName: btn.dataset.appName, + groupId: btn.dataset.groupId, + groupName: btn.dataset.groupName, + type: btn.dataset.type, + }); + }); + }); + wrap.querySelectorAll('.asg-actions, .asg-row .entra-link').forEach(el => { + el.addEventListener('click', e => e.stopPropagation()); + }); + // Flow-Nodes (Dependencies / Supersedence) — Klicks fuehren zur Target-App + attachFlowNodeHandlers(wrap); + // Klicks innerhalb der Flow-Container sollen nicht den Row-Toggle ausloesen + wrap.querySelectorAll('.flow-container').forEach(el => { + el.addEventListener('click', e => e.stopPropagation()); + }); + // Klick auf Zeile = Detail-Toggle. Klicks auf interne Buttons/Links sind oben + // bereits mit stopPropagation versehen oder triggern via kein Bubble-Toggle. + const rowEl = wrap.querySelector('.app-row'); + if (rowEl) { + rowEl.addEventListener('click', e => { + // Direkte Links (App-Name in Intune oeffnen, Source-Badge) nicht abfangen + if (e.target.closest('a')) return; + // Buttons innerhalb der Zeile (Type-Btn, Create-Btn) haben ihre eigenen Handler + if (e.target.closest('button')) return; + const id = rowEl.dataset.appId; + if (id) toggleAppExpand(id); + }); + } + // Copy-Buttons im Detail-Panel + wrap.querySelectorAll('.app-detail-copy').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const txt = btn.dataset.copy || ''; + navigator.clipboard.writeText(txt).then( + () => { btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 900); toast('In Zwischenablage kopiert', 'ok'); }, + () => toast('Kopieren fehlgeschlagen', 'err') + ); + }); + }); +} + +function onTypeButtonClick(btnEl) { + const appId = btnEl.dataset.appId; + const type = btnEl.dataset.type; + const app = State.apps.find(a => a.AppId === appId); + if (!app) return; + const groups = (type === 'Available' ? app.AvailableGroups : app.RequiredGroups).filter(g => !g.IsNative); + if (groups.length === 0) return; + + if (groups.length === 1) { + const g = groups[0]; + toggleSession({ appId, groupId: g.GroupId, groupName: g.GroupName, type }); + return; + } + + // Mehrere Gruppen → Popover mit Auswahl + showGroupPicker(btnEl, app, groups, type); +} + +let pickerEl = null; +function closeGroupPicker() { + if (pickerEl) { pickerEl.remove(); pickerEl = null; } + document.removeEventListener('click', onPickerOutsideClick, true); + document.removeEventListener('keydown', onPickerEscape); +} +function onPickerOutsideClick(e) { + if (pickerEl && !pickerEl.contains(e.target)) closeGroupPicker(); +} +function onPickerEscape(e) { + if (e.key === 'Escape') closeGroupPicker(); +} + +function showGroupPicker(anchorEl, app, groups, type) { + closeGroupPicker(); + + const queuedSet = new Set( + State.session.filter(s => s.appId === app.AppId && s.type === type).map(s => s.groupId) + ); + + pickerEl = document.createElement('div'); + pickerEl.className = `group-picker picker-${type.toLowerCase()}`; + pickerEl.innerHTML = ` +
    ${escapeHtml(type)} — Gruppe wählen
    +
    + ${groups.map(g => { + const queued = queuedSet.has(g.GroupId); + const memb = getCachedMembership(g.GroupId); + let mLabel = ''; + if (memb) { + if (memb.status === 'Full') mLabel = `Mitglied`; + else if (memb.status === 'Partial') mLabel = `${memb.matched}/${memb.total}`; + } + const sideActions = ` + + ${entraGroupLink(g.GroupId, g.GroupName)} + ${membersTabLink(g.GroupId, g.GroupName)} + + + `; + return ` +
    + + ${sideActions} +
    + `; + }).join('')} +
    + `; + document.body.appendChild(pickerEl); + + // Position relativ zum Button + const r = anchorEl.getBoundingClientRect(); + const pw = pickerEl.offsetWidth; + const ph = pickerEl.offsetHeight; + let left = r.left; + let top = r.bottom + 4; + if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8; + if (top + ph > window.innerHeight - 8) top = r.top - ph - 4; + pickerEl.style.left = left + 'px'; + pickerEl.style.top = top + 'px'; + + pickerEl.querySelectorAll('.picker-item').forEach(item => { + item.addEventListener('click', e => { + e.stopPropagation(); + toggleSession({ + appId: app.AppId, + groupId: item.dataset.groupId, + groupName: item.dataset.groupName, + type, + }); + closeGroupPicker(); + }); + }); + + // Side-Actions (Entra-Link, Members-Tab) duerfen den Picker nicht schliessen + // bzw. die Gruppe nicht zur Session togglen. Links lassen wir natuerlich oeffnen. + pickerEl.querySelectorAll('[data-stop]').forEach(box => { + box.addEventListener('click', e => e.stopPropagation()); + }); + + // Zuweisung entfernen + pickerEl.querySelectorAll('[data-remove-assign]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + const ctx = { + appId: btn.dataset.appId, + appName: btn.dataset.appName, + groupId: btn.dataset.groupId, + groupName: btn.dataset.groupName, + type: btn.dataset.type, + }; + closeGroupPicker(); + removeAssignmentWithConfirm(ctx); + }); + }); + + // Click outside / Escape schliessen + setTimeout(() => { + document.addEventListener('click', onPickerOutsideClick, true); + document.addEventListener('keydown', onPickerEscape); + }, 0); +} + +// ============================================================= +// Membership +// ============================================================= + +function getCachedMembership(groupId) { + if (State.selectedTargetIds.size === 0) return null; + return State.membershipCache.get(membershipKey(groupId)); +} + +function membershipKey(groupId) { + const ids = [...State.selectedTargetIds].sort().join(','); + return groupId + '|' + ids; +} + +let membershipAbort = null; + +function abortMembershipFetch() { + if (membershipAbort) { + try { membershipAbort.abort(); } catch {} + membershipAbort = null; + } +} + +async function updateMembershipForVisibleApps() { + if (State.selectedTargetIds.size === 0) return; + const visible = getFilteredApps(); + const groupIds = new Set(); + visible.forEach(a => { + a.AvailableGroups.concat(a.RequiredGroups).forEach(g => { + if (!g.IsNative && !State.membershipCache.has(membershipKey(g.GroupId))) { + groupIds.add(g.GroupId); + } + }); + }); + if (groupIds.size === 0) return; + + // Vorherige Bulk-Anfrage abbrechen wenn neue Ziele/Apps + abortMembershipFetch(); + membershipAbort = new AbortController(); + + const targets = [...State.selectedTargetIds]; + const groupIdsArr = [...groupIds]; + try { + const resp = await fetch('/api/membership/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targets, groupIds: groupIdsArr }), + signal: membershipAbort.signal, + }); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.memberships) return; + for (const [gid, info] of Object.entries(data.memberships)) { + State.membershipCache.set(membershipKey(gid), info); + } + renderApps(); + renderExisting(); + } catch (e) { + if (e.name === 'AbortError') return; + // Stille — Mitgliedschaft ist Hintergrund-Info + } +} + +// ============================================================= +// Session (Cart) +// ============================================================= + +document.getElementById('btnSessionClear').addEventListener('click', () => { + if (State.session.length === 0) return; + State.session = []; + renderSession(); + renderApps(); + updateStepper(); +}); + +document.getElementById('btnApply').addEventListener('click', applyAssignments); + +function toggleSession({ appId, groupId, groupName, type }) { + // Eine Session entspricht GENAU EINEM Empfaenger (Gruppe ODER User). + // Toggle: existieren bereits Sessions fuer diese App+Intune-Group+Type + // mit den aktuell gewaehlten Empfaengern, werden diese entfernt. + // Andernfalls wird pro Empfaenger eine eigene Session angelegt. + const allSel = getAllSelected(); + const matching = State.session.filter(s => s.appId === appId && s.groupId === groupId && s.type === type); + if (matching.length > 0 && allSel.length === 0) { + // Keine Auswahl → toggle = entfernen aller Eintraege fuer diese App-Group-Combo + State.session = State.session.filter(s => !matching.includes(s)); + renderSession(); + renderApps(); + updateStepper(); + return; + } + if (allSel.length === 0) { + toast('Wähle erst Abteilungen oder Benutzer aus', 'warn'); + return; + } + + // Pruefen, ob fuer alle aktuellen Empfaenger schon Sessions existieren + // → dann komplett entfernen (Toggle aus). + const matchingByTarget = new Map(matching.map(s => [s.scope?.[0]?.id, s])); + const allCovered = allSel.every(t => matchingByTarget.has(t.Id)); + if (allCovered && matching.length === allSel.length) { + State.session = State.session.filter(s => !matching.includes(s)); + renderSession(); + renderApps(); + updateStepper(); + return; + } + + // Sonst: fuer jeden Empfaenger, der noch keine Session hat, eine neue anlegen + const app = State.apps.find(a => a.AppId === appId); + for (const t of allSel) { + if (matchingByTarget.has(t.Id)) continue; + State.session.push({ + id: crypto.randomUUID(), + appId, + appName: app ? app.AppName : '?', + appType: app ? app.AppType : '', + groupId, + groupName, + type, + scope: [{ id: t.Id, name: t.DisplayName, mode: t._mode }], + }); + } + renderSession(); + renderApps(); + updateStepper(); +} + +// Helfer: gesammelte Auswahl ueber ALLE Modi (current + cached) +function collectSelectedByMode(mode) { + if (State.mode === mode) { + return State.targets.filter(t => State.selectedTargetIds.has(t.Id)); + } + const cached = State.modeCache.get(mode); + if (!cached) return []; + return cached.targets.filter(t => cached.selectedIds.has(t.Id)); +} +function getAllSelected() { + const out = [ + ...collectSelectedByMode('dept').map(t => ({ ...t, _mode: 'dept' })), + ...collectSelectedByMode('rpa').map(t => ({ ...t, _mode: 'rpa' })), + ...collectSelectedByMode('user').map(t => ({ ...t, _mode: 'user' })), + ]; + // Auch in aufgeklappten Gruppen markierte User mit aufnehmen — der User + // erwartet, dass das Anhaken sofort wirkt, ohne erst "Benutzer uebernehmen" + // klicken zu muessen. Dedupliziert ueber Id, damit ein bereits committed + // User nicht doppelt erscheint. + const seen = new Set(out.map(t => t.Id)); + const collectExpand = (expandedMap) => { + if (!expandedMap) return; + for (const entry of expandedMap.values()) { + if (!entry || !entry.selected || !entry.members) continue; + const byId = new Map(entry.members.map(m => [m.Id, m])); + for (const id of entry.selected) { + if (seen.has(id)) continue; + const m = byId.get(id); + if (!m) continue; + seen.add(id); + out.push({ ...m, _mode: 'user' }); + } + } + }; + collectExpand(State.expandedGroups); + for (const m of ['dept','rpa','user']) { + if (m === State.mode) continue; + const c = State.modeCache.get(m); + if (c) collectExpand(c.expandedGroups); + } + return out; +} + +function renderSession() { + const list = document.getElementById('sessionList'); + const cnt = document.getElementById('sessionCount'); + const btnApply = document.getElementById('btnApply'); + const btnApplyLabel = document.getElementById('btnApplyLabel'); + const btnClear = document.getElementById('btnSessionClear'); + + cnt.textContent = State.session.length; + + // Aktuelle Auswahl (kann von Session-Scopes abweichen!) + // Auch "noch nicht uebernommene" Einzelmitglieder aus aufgeklappten Gruppen + // mitzaehlen — der User erwartet sofortiges Feedback beim Anhaken. + const currentDept = collectSelectedByMode('dept').length + collectSelectedByMode('rpa').length; + const userIds = new Set(collectSelectedByMode('user').map(t => t.Id)); + const collectExpandSelected = (expandedMap) => { + if (!expandedMap) return; + for (const entry of expandedMap.values()) { + if (entry && entry.selected) { + for (const id of entry.selected) userIds.add(id); + } + } + }; + collectExpandSelected(State.expandedGroups); + for (const m of ['dept','rpa','user']) { + if (m === State.mode) continue; + const c = State.modeCache.get(m); + if (c) collectExpandSelected(c.expandedGroups); + } + const currentUser = userIds.size; + + // Tatsaechliche Vorgaenge aus den eingefrorenen Per-Item-Scopes + let groupOps = 0, userOps = 0; + for (const s of State.session) { + if (!s.scope) continue; + for (const sc of s.scope) { + if (sc.mode === 'user') { userOps++; } + else { groupOps++; } + } + } + const total = groupOps + userOps; + const sessions = State.session.length; + + document.getElementById('sumGroupsSel').textContent = currentDept; + document.getElementById('sumUsersSel').textContent = currentUser; + document.getElementById('sumGroupsOps').textContent = groupOps; + document.getElementById('sumUsersOps').textContent = userOps; + document.getElementById('sumTotal').textContent = total; + + btnApply.disabled = (sessions === 0 || total === 0); + btnApplyLabel.textContent = total > 0 + ? `${total} Vorgang${total === 1 ? '' : 'e'} ausführen` + : 'Zuweisungen ausführen'; + + btnClear.hidden = sessions === 0; + + if (State.session.length === 0) { + list.innerHTML = emptyState({ + small: true, + title: 'Keine geplanten Zuweisungen', + text: 'Klicke auf "Available" oder "Required" bei einer App um eine Zuweisung hinzuzufügen.', + }); + return; + } + + list.innerHTML = State.session.map(s => { + const scope = s.scope || []; + // Eine Session = genau ein Empfaenger. Falls aus Altdaten noch ein Multi- + // Scope existiert, fallen wir auf eine kompakte Anzeige zurueck. + const single = scope.length === 1 ? scope[0] : null; + let scopeBlock = ''; + if (single) { + const isUser = single.mode === 'user'; + const tagCls = isUser ? 'scope-user' : 'scope-dept'; + const tagLbl = isUser ? '1 Benutzer' : '1 Gruppe'; + scopeBlock = `
    + ${tagLbl} + ${escapeHtml(single.name)} +
    `; + } else if (scope.length > 1) { + const deptN = scope.filter(x => x.mode !== 'user').length; + const userN = scope.filter(x => x.mode === 'user').length; + const tags = []; + if (deptN > 0) tags.push(`${deptN} Gruppe${deptN === 1 ? '' : 'n'}`); + if (userN > 0) tags.push(`${userN} Benutzer`); + const tip = scope.map(x => x.name).join('\n'); + scopeBlock = `
    ${tags.join('')}
    `; + } + return `
    +
    +
    ${escapeHtml(s.appName)}
    +
    ${escapeHtml(s.groupName)}
    + ${scopeBlock} +
    + ${s.type === 'Available' ? 'A' : 'R'} + +
    `; + }).join(''); + + list.querySelectorAll('.session-remove').forEach(b => { + b.addEventListener('click', () => { + State.session = State.session.filter(x => x.id !== b.dataset.id); + renderSession(); + renderApps(); + updateStepper(); + }); + }); +} + +// ============================================================= +// Existing assignments +// ============================================================= + +function renderExisting() { + const list = document.getElementById('existingList'); + if (State.selectedTargetIds.size === 0 || State.apps.length === 0) { + list.innerHTML = emptyState({ + small: true, + title: 'Keine Auswahl', + text: 'Wähle Empfänger und lade Apps, um Mitgliedschaften zu sehen.', + }); + return; + } + const items = []; + for (const app of State.apps) { + const flags = []; + for (const g of app.AvailableGroups) { + if (g.IsNative) { flags.push({ cls: 'n', label: 'A·N' }); continue; } + const m = getCachedMembership(g.GroupId); + if (m && (m.status === 'Full' || m.status === 'Partial')) { + flags.push({ cls: 'a', label: 'A' + (m.status === 'Partial' ? '·◐' : '·✓') }); + } + } + for (const g of app.RequiredGroups) { + if (g.IsNative) { flags.push({ cls: 'n', label: 'R·N' }); continue; } + const m = getCachedMembership(g.GroupId); + if (m && (m.status === 'Full' || m.status === 'Partial')) { + flags.push({ cls: 'r', label: 'R' + (m.status === 'Partial' ? '·◐' : '·✓') }); + } + } + if (flags.length > 0) items.push({ app, flags }); + } + if (items.length === 0) { + list.innerHTML = emptyState({ + small: true, + title: 'Nichts zugewiesen', + text: 'Keine bestehenden Zuweisungen für die Auswahl.', + }); + return; + } + list.innerHTML = items.map(({ app, flags }) => ` +
    +
    ${escapeHtml(app.AppName)}
    +
    + ${flags.map(f => `${escapeHtml(f.label)}`).join('')} +
    +
    + `).join(''); +} + +// ============================================================= +// Side tabs (Cart / Existing) +// ============================================================= + +document.querySelectorAll('.side-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.side-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + State.sideTab = tab.dataset.side; + document.getElementById('sidePaneSession').classList.toggle('hidden', State.sideTab !== 'session'); + document.getElementById('sidePaneExisting').classList.toggle('hidden', State.sideTab !== 'existing'); + }); +}); + +// ============================================================= +// Apply +// ============================================================= + +async function applyAssignments() { + if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } + if (State.session.length === 0) { toast('Keine geplanten Zuweisungen', 'warn'); return; } + + // Flache Ops-Liste aus den Per-Item-Snapshots bauen + const ops = []; + let deptOps = 0, userOps = 0; + for (const s of State.session) { + if (!s.scope || s.scope.length === 0) continue; + for (const sc of s.scope) { + ops.push({ + targetId: sc.id, + targetName: sc.name, + targetType: sc.mode, + appId: s.appId, + appName: s.appName, + groupId: s.groupId, + groupName: s.groupName, + type: s.type, + }); + if (sc.mode === 'user') userOps++; else deptOps++; + } + } + const total = ops.length; + if (total === 0) { toast('Keine ausführbaren Vorgänge', 'warn'); return; } + + document.getElementById('confirmText').innerHTML = ` + ${total} Vorgang${total === 1 ? '' : 'e'} wird ausgeführt: +
      + ${deptOps > 0 ? `
    • ${deptOps} auf Abteilungsgruppen
    • ` : ''} + ${userOps > 0 ? `
    • ${userOps} auf Einzelbenutzer
    • ` : ''} +
    `; + openModal('modalConfirm'); + + // wait for confirm or cancel via promise + const userConfirmed = await new Promise(resolve => { + const ok = document.getElementById('btnConfirmApply'); + const modal = document.getElementById('modalConfirm'); + function cleanup(value) { + ok.removeEventListener('click', onOk); + modal.querySelectorAll('[data-close]').forEach(x => x.removeEventListener('click', onCancel)); + resolve(value); + } + function onOk() { closeModal('modalConfirm'); cleanup(true); } + function onCancel() { cleanup(false); } + ok.addEventListener('click', onOk); + modal.querySelectorAll('[data-close]').forEach(x => x.addEventListener('click', onCancel)); + }); + + if (!userConfirmed) return; + + // Laufende Hintergrund-Anfragen abbrechen damit der Apply nicht hinten in der Queue hängt + abortMembershipFetch(); + + setLoading(`Führe ${total} Vorgang${total === 1 ? '' : 'e'} aus...`); + + // 60s-Timeout — sonst hängt die UI ewig wenn das Backend stirbt + const ctrl = new AbortController(); + const timeoutId = setTimeout(() => ctrl.abort(), 60000); + + try { + const resp = await fetch('/api/assignments/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ops }), + signal: ctrl.signal, + }); + clearTimeout(timeoutId); + let res = {}; + try { res = await resp.json(); } catch {} + if (!resp.ok) throw new Error(res.error || `HTTP ${resp.status}`); + // Defensiv: falls Backend versehentlich Array (mit Pipeline-Junk + Response) liefert, + // nimm das letzte Hashtable-Element + if (Array.isArray(res)) { + res = res.filter(x => x && typeof x === 'object').pop() || {}; + } + showResult(res); + toast(`${res.success} OK · ${res.errors} Fehler · ${res.skipped} Skip`, res.errors > 0 ? 'warn' : 'ok'); + State.session = []; + State.membershipCache.clear(); + renderSession(); + renderApps(); + renderExisting(); + updateMembershipForVisibleApps(); + updateStepper(); + } catch (e) { + clearTimeout(timeoutId); + if (e.name === 'AbortError') { + // Liveness-Check: lebt der Server noch? + try { + const ping = await fetch('/api/ping').then(r => r.json()); + toast(`Apply-Timeout nach 60s. Server lebt (Ping ${ping.time}). Apply hängt im Backend — siehe PowerShell-Konsole.`, 'err', 'Timeout'); + } catch { + toast('Apply-Timeout nach 60s. Server reagiert nicht — PowerShell-Konsole prüfen oder Server neu starten.', 'err', 'Server tot?'); + } + } else { + toast(e.message, 'err', 'Apply fehlgeschlagen'); + } + } finally { + clearLoading(); + } +} + +function showResult(res) { + const success = Number(res.success) || 0; + const skipped = Number(res.skipped) || 0; + const errors = Number(res.errors) || 0; + const total = Number(res.total) || 0; + const details = Array.isArray(res.details) ? res.details : []; + + document.getElementById('resOk').textContent = success; + document.getElementById('resSkip').textContent = skipped; + document.getElementById('resErr').textContent = errors; + document.getElementById('resTotal').textContent = total; + + // Status-Banner + const banner = document.getElementById('resBanner'); + let bannerCls = 'res-banner-ok'; + let bannerIco = '✓'; + let bannerText = `${success} Vorgang${success === 1 ? '' : 'e'} erfolgreich abgeschlossen`; + if (errors > 0) { + bannerCls = 'res-banner-err'; + bannerIco = '!'; + bannerText = `${errors} von ${total} Vorgang${total === 1 ? '' : 'en'} fehlgeschlagen`; + } else if (success === 0 && skipped > 0) { + bannerCls = 'res-banner-warn'; + bannerIco = '·'; + bannerText = `${skipped} Vorgang${skipped === 1 ? '' : 'e'} übersprungen (nichts zu tun)`; + } + banner.className = 'res-banner ' + bannerCls; + banner.innerHTML = `${bannerIco}${escapeHtml(bannerText)}`; + + // Details-Liste + const det = document.getElementById('resDetails'); + if (details.length === 0) { + det.innerHTML = ''; + } else { + const rows = details.map(d => { + const status = String(d.status || ''); + const cls = status === 'Success' || status === 'AlreadyMember' ? 'ok' + : status === 'Skipped' ? 'warn' + : status === 'Error' ? 'err' : ''; + const sym = cls === 'ok' ? '✓' : cls === 'warn' ? '·' : '✕'; + return `
    + ${sym} +
    +
    ${escapeHtml(d.target || '')} → ${escapeHtml(d.app || '')}
    +
    ${escapeHtml(d.group || '')} ${escapeHtml(d.type || '')}
    + ${d.message ? `
    ${escapeHtml(d.message)}
    ` : ''} +
    +
    `; + }).join(''); + det.innerHTML = `
    Details (${details.length})
    ${rows}
    `; + } + + // Report-Link als prominenter Button + const link = document.getElementById('resReportLink'); + if (res.reportUrl) { + link.innerHTML = `
    + + HTML-Report öffnen + `; + } else { + link.innerHTML = `
    ⚠ Kein Report-URL in der Response — siehe Debug unten
    `; + } + + // Debug-Sektion mit der RAW Response — damit wir sehen was tatsaechlich ankommt + const dbg = document.getElementById('resDebug'); + if (dbg) { + const raw = JSON.stringify(res, null, 2); + dbg.innerHTML = `
    + Raw Output${res.build ? ' (Backend-Build: ' + escapeHtml(res.build) + ')' : ' (kein build-Stamp — Backend wurde nicht neu gestartet!)'} +
    ${escapeHtml(raw)}
    +
    `; + } + + console.log('[APPLY] Response:', res); + openModal('modalResult'); +} + +// ============================================================= +// Modals +// ============================================================= + +function openModal(id) { document.getElementById(id).classList.remove('hidden'); } +function closeModal(id) { document.getElementById(id).classList.add('hidden'); } + +document.querySelectorAll('[data-close]').forEach(el => { + el.addEventListener('click', () => { + const modal = el.closest('.modal'); + if (modal) modal.classList.add('hidden'); + }); +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); + } +}); + +// ============================================================= +// Create Required Group Modal +// ============================================================= + +let createGroupContext = null; +let cgCheckTimer = null; + +// Naming-Schemas aus Settings holen. Fallback auf Defaults wenn Settings noch +// nicht geladen sind (z.B. waehrend des allerersten Init-Renders). +function getNamingFor(intent) { + const s = SettingsState && SettingsState.current; + const block = intent === 'available' + ? (s && s.availableGroupNaming) + : (s && s.requiredGroupNaming); + const defPrefix = 'intune-win-app-'; + const defSuffix = intent === 'available' ? '-available' : '-required'; + return { + prefix: (block && block.prefix) || defPrefix, + suffix: (block && block.suffix) || defSuffix, + }; +} + +function appSlug(appName) { + return appName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '').toLowerCase(); +} + +function suggestedGroupName(appName, intent) { + const { prefix, suffix } = getNamingFor(intent); + return `${prefix}${appSlug(appName)}${suffix}`; +} + +function getCurrentCgMode() { + const el = document.querySelector('input[name="cgMode"]:checked'); + return el ? el.value : 'req-group'; +} + +// Modus -> { intent, isGroup, target } — single source of truth +function cgModeMeta(mode) { + switch (mode) { + case 'req-group': return { intent: 'required', isGroup: true, target: null }; + case 'req-allusers': return { intent: 'required', isGroup: false, target: 'ALL_USERS' }; + case 'req-alldevices': return { intent: 'required', isGroup: false, target: 'ALL_DEVICES' }; + case 'avail-group': return { intent: 'available', isGroup: true, target: null }; + case 'avail-allusers': return { intent: 'available', isGroup: false, target: 'ALL_USERS' }; + case 'avail-alldevices':return { intent: 'available', isGroup: false, target: 'ALL_DEVICES' }; + } + return { intent: 'required', isGroup: true, target: null }; +} + +function openCreateGroupModal(appId, appName) { + createGroupContext = { appId, appName }; + document.getElementById('cgAppName').value = appName; + + // Bereits zugewiesene Native-Targets aus dem App-Cache ermitteln. + // ALL_USERS und ALL_DEVICES koennen je Intent nur EINMAL existieren — + // wenn schon da, Option deaktivieren statt einen 409 vom Backend zu kassieren. + const app = State.apps.find(a => a.AppId === appId); + const has = (arr, gid) => !!(app && arr && arr.some(g => g.GroupId === gid)); + const occupied = { + 'req-allusers': has(app && app.RequiredGroups, 'ALL_USERS'), + 'req-alldevices': has(app && app.RequiredGroups, 'ALL_DEVICES'), + 'avail-allusers': has(app && app.AvailableGroups, 'ALL_USERS'), + 'avail-alldevices': has(app && app.AvailableGroups, 'ALL_DEVICES'), + }; + + document.querySelectorAll('input[name="cgMode"]').forEach(radio => { + const isOccupied = !!occupied[radio.value]; + radio.disabled = isOccupied; + radio.checked = false; // Reset jeder Auswahl, Default unten gesetzt + const card = radio.closest('.cg-mode'); + if (!card) return; + card.classList.toggle('cg-mode--disabled', isOccupied); + + const desc = card.querySelector('.cg-mode-desc'); + if (desc) { + // Original-Description am ersten Aufruf merken, sonst wuerde wiederholtes + // Oeffnen den Text dauerhaft auf "bereits zugewiesen" pinnen. + if (!desc.dataset.origDesc) desc.dataset.origDesc = desc.textContent; + desc.textContent = isOccupied + ? 'Bereits zugewiesen — keine zweite Zuweisung möglich.' + : desc.dataset.origDesc; + } + }); + + // Default-Modus: erste verfuegbare Option (in der Reihenfolge: req-group ist + // immer da, fallback auf naechstes). Garantiert konsistente Vorauswahl. + const order = ['req-group','req-allusers','req-alldevices','avail-group','avail-allusers','avail-alldevices']; + for (const val of order) { + if (!occupied[val]) { + const r = document.querySelector(`input[name="cgMode"][value="${val}"]`); + if (r) { r.checked = true; break; } + } + } + document.getElementById('cgAssignToApp').checked = true; + applyCgModeUi(); + openModal('modalCreateGroup'); +} + +// Zeigt/versteckt das Namens-Input + Vorschlag je nach Modus. +// All-Users/All-Devices brauchen weder Name noch Validierung. +function applyCgModeUi() { + const mode = getCurrentCgMode(); + const meta = cgModeMeta(mode); + const nameGroup = document.getElementById('cgGroupNameGroup'); + const assignWrap = document.getElementById('cgAssignToAppWrap'); + const createBtn = document.getElementById('cgCreate'); + + const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; + if (meta.isGroup) { + nameGroup.hidden = false; + assignWrap.hidden = false; + document.getElementById('cgGroupName').value = suggestedGroupName(createGroupContext.appName, meta.intent); + document.querySelector('#cgAssignToAppWrap span').textContent = + `Gruppe direkt der App als ${intentLabel} zuweisen`; + checkGroupName(); + createBtn.textContent = `${intentLabel}-Gruppe erstellen`; + } else { + nameGroup.hidden = true; + assignWrap.hidden = true; + createBtn.disabled = false; + const tgtLbl = meta.target === 'ALL_USERS' ? 'All Users' : 'All Devices'; + createBtn.textContent = `Als ${intentLabel} für ${tgtLbl} zuweisen`; + } +} + +document.querySelectorAll('input[name="cgMode"]').forEach(r => { + r.addEventListener('change', applyCgModeUi); +}); + +document.getElementById('cgGroupName').addEventListener('input', () => { + clearTimeout(cgCheckTimer); + cgCheckTimer = setTimeout(checkGroupName, 400); +}); + +async function checkGroupName() { + const name = document.getElementById('cgGroupName').value.trim(); + const status = document.getElementById('cgStatus'); + const btn = document.getElementById('cgCreate'); + if (!name) { + status.textContent = 'Name darf nicht leer sein'; + status.className = 'form-hint err'; + btn.disabled = true; + return; + } + status.textContent = 'Prüfe...'; + status.className = 'form-hint'; + btn.disabled = true; + try { + const res = await api('/api/groups/check', { method: 'POST', body: { displayName: name } }); + if (res.exists) { + status.textContent = '✕ Gruppe existiert bereits'; + status.className = 'form-hint err'; + } else { + status.textContent = '✓ Name ist verfügbar'; + status.className = 'form-hint ok'; + btn.disabled = false; + } + } catch (e) { + status.textContent = 'Prüfung fehlgeschlagen: ' + e.message; + status.className = 'form-hint err'; + } +} + +document.getElementById('cgCreate').addEventListener('click', async () => { + if (!createGroupContext) return; + const meta = cgModeMeta(getCurrentCgMode()); + + if (meta.isGroup) { + // Gruppe erstellen + zuweisen + const { prefix, suffix } = getNamingFor(meta.intent); + const raw = document.getElementById('cgGroupName').value.trim(); + // Praefix/Suffix entfernen damit der Backend-Slug-Builder die Saettigung steuert + const customName = raw + .replace(new RegExp('^' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), '') + .replace(new RegExp(suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), ''); + const assignToApp = document.getElementById('cgAssignToApp').checked; + setLoading(`Erstelle ${meta.intent === 'available' ? 'Available' : 'Required'}-Gruppe...`); + try { + const res = await api('/api/groups', { + method: 'POST', + body: { + appId: createGroupContext.appId, + appName: createGroupContext.appName, + customName, + assignToApp, + intent: meta.intent, + } + }); + toast(`Gruppe erstellt: ${res.displayName}` + (res.assigned ? ' (zugewiesen)' : ''), 'ok'); + closeModal('modalCreateGroup'); + if (assignToApp) await loadApps(true); + } catch (e) { + toast(e.message, 'err', 'Erstellen fehlgeschlagen'); + } finally { + clearLoading(); + } + return; + } + + // Native Zuweisung (All Users / All Devices) — Intent kann required ODER available sein + const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; + const targetLabel = meta.target === 'ALL_USERS' ? 'All Users' : 'All Devices'; + setLoading(`Weise als ${intentLabel} für ${targetLabel} zu...`); + try { + await api(`/api/apps/${encodeURIComponent(createGroupContext.appId)}/assignments`, { + method: 'POST', + body: { intent: meta.intent, target: meta.target }, + }); + toast(`${intentLabel}-Zuweisung (${targetLabel}) hinzugefügt`, 'ok'); + closeModal('modalCreateGroup'); + await loadApps(true); + } catch (e) { + toast(e.message, 'err', 'Zuweisung fehlgeschlagen'); + } finally { + clearLoading(); + } +}); + +// ============================================================= +// App umbenennen +// ============================================================= + +let renameAppContext = null; + +function openRenameAppModal(appId, appName) { + renameAppContext = { appId, appName }; + document.getElementById('raOldName').textContent = appName; + const input = document.getElementById('raNewName'); + input.value = appName; + document.getElementById('raError').textContent = ''; + openModal('modalRenameApp'); + // Im naechsten Frame fokussieren + komplett markieren, damit der User + // sofort lostippen oder gezielt aendern kann. + requestAnimationFrame(() => { + input.focus(); + input.select(); + }); +} + +async function submitRenameApp() { + if (!renameAppContext) return; + const input = document.getElementById('raNewName'); + const errEl = document.getElementById('raError'); + const newName = input.value.trim(); + errEl.textContent = ''; + + if (newName.length < 1) { errEl.textContent = 'Name darf nicht leer sein.'; return; } + if (newName.length > 256) { errEl.textContent = 'Name zu lang (max. 256 Zeichen).'; return; } + if (newName === renameAppContext.appName) { + closeModal('modalRenameApp'); + return; + } + + setLoading(`Benenne um zu "${newName}"...`); + try { + const res = await api(`/api/apps/${encodeURIComponent(renameAppContext.appId)}`, { + method: 'PATCH', + body: { displayName: newName }, + }); + // Cache lokal nachziehen + const app = State.apps.find(a => a.AppId === renameAppContext.appId); + if (app) app.AppName = res.appName || newName; + closeModal('modalRenameApp'); + renderApps(); + toast(`Umbenannt: "${renameAppContext.appName}" → "${newName}"`, 'ok'); + } catch (e) { + errEl.textContent = e.message; + } finally { + clearLoading(); + } +} + +// --- Setup-Datei aktualisieren --- +let updateContentContext = null; + +function openUpdateContentModal(appId, appName, appType, appVersion) { + updateContentContext = { appId, appName, appType }; + document.getElementById('ucAppName').textContent = appName; + document.getElementById('ucFilePath').value = ''; + document.getElementById('ucVersion').value = appVersion || ''; + document.getElementById('ucConfirm').checked = false; + document.getElementById('ucError').textContent = ''; + const prog = document.getElementById('ucProgress'); + prog.classList.add('hidden'); prog.innerHTML = ''; + document.getElementById('ucSubmit').disabled = true; + openModal('modalUpdateContent'); + requestAnimationFrame(() => document.getElementById('ucFilePath').focus()); +} + +async function submitUpdateContent() { + if (!updateContentContext) return; + const filePath = document.getElementById('ucFilePath').value.trim(); + const version = document.getElementById('ucVersion').value.trim(); + const errEl = document.getElementById('ucError'); + const prog = document.getElementById('ucProgress'); + const submit = document.getElementById('ucSubmit'); + errEl.textContent = ''; + + if (!filePath) { errEl.textContent = 'Bitte einen Dateipfad angeben.'; return; } + if (!document.getElementById('ucConfirm').checked) { errEl.textContent = 'Bitte die Bestätigung anhaken.'; return; } + + submit.disabled = true; + prog.classList.remove('hidden'); + prog.innerHTML = '
    Lade hoch und aktiviere — das kann je nach Dateigröße dauern…'; + + try { + const body = { filePath }; + if (version) body.displayVersion = version; + const res = await api(`/api/apps/${encodeURIComponent(updateContentContext.appId)}/content`, { + method: 'POST', + body, + timeoutMs: 1800000, // 30 Min — grosse Pakete + Commit-Polling + }); + closeModal('modalUpdateContent'); + // App-Liste neu laden (Backend hat den Cache geleert) + await loadApps(true, { silent: true }); + State.appDetails.delete(updateContentContext.appId); + renderApps(); + toast(`Setup-Datei aktualisiert: "${updateContentContext.appName}" → Version ${res.contentVersion}`, 'ok'); + } catch (e) { + errEl.textContent = e.message; + prog.classList.add('hidden'); + submit.disabled = false; + } +} + +// ============================================================= +// Destruktive Aktionen: App loeschen / Zuweisung entfernen +// ============================================================= + +async function deleteAppWithConfirm(appId, appName) { + if (!appId) return; + const msg = `App aus Intune LÖSCHEN?\n\n ${appName}\n\nDas entfernt die App und alle Zuweisungen aus Intune. Die Aktion kann nicht rückgängig gemacht werden.`; + if (!window.confirm(msg)) return; + + setLoading(`Lösche "${appName}"...`); + try { + await api(`/api/apps/${encodeURIComponent(appId)}`, { method: 'DELETE' }); + State.apps = State.apps.filter(a => a.AppId !== appId); + State.appDetails.delete(appId); + State.expandedApps.delete(appId); + State.session = State.session.filter(s => s.appId !== appId); + State.membershipCache.clear(); + renderApps(); + renderSession(); + renderExisting(); + updateStepper(); + toast(`App "${appName}" gelöscht`, 'ok'); + } catch (e) { + toast(e.message, 'err', 'Löschen fehlgeschlagen'); + } finally { + clearLoading(); + } +} + +async function removeAssignmentWithConfirm(ctx) { + const { appId, appName, groupId, groupName, type } = ctx; + if (!appId || !groupId) return; + if (groupId === 'ALL_USERS' || groupId === 'ALL_DEVICES') { + toast('Native Zuweisungen (All Users / All Devices) können hier nicht entfernt werden.', 'warn'); + return; + } + const typeLbl = type === 'required' ? 'Required' : 'Available'; + const msg = `Zuweisung entfernen?\n\nApp: ${appName}\nGruppe: ${groupName}\nTyp: ${typeLbl}\n\nDie Gruppe ist danach der App nicht mehr zugewiesen. Mitgliedschaften der Gruppe bleiben unverändert.`; + if (!window.confirm(msg)) return; + + setLoading(`Entferne Zuweisung "${groupName}"...`); + try { + const url = `/api/apps/${encodeURIComponent(appId)}/assignments/${encodeURIComponent(groupId)}?type=${encodeURIComponent(type)}`; + await api(url, { method: 'DELETE' }); + + // Lokal nachziehen — Backend tut das ebenfalls im Cache + const app = State.apps.find(a => a.AppId === appId); + if (app) { + if (type === 'available') app.AvailableGroups = app.AvailableGroups.filter(g => g.GroupId !== groupId); + if (type === 'required') app.RequiredGroups = app.RequiredGroups.filter(g => g.GroupId !== groupId); + app.AvailableCount = app.AvailableGroups.length; + app.RequiredCount = app.RequiredGroups.length; + } + // Geplante Zuweisungen auf die gleiche Gruppe ggf. ebenfalls aufraeumen — sie + // wuerden ja im naechsten Apply gegen eine nicht mehr zugewiesene Gruppe laufen. + State.session = State.session.filter(s => !(s.appId === appId && s.groupId === groupId && (s.type || '').toLowerCase() === type)); + State.membershipCache.clear(); + renderApps(); + renderSession(); + renderExisting(); + updateStepper(); + toast(`Zuweisung "${groupName}" entfernt`, 'ok'); + } catch (e) { + toast(e.message, 'err', 'Entfernen fehlgeschlagen'); + } finally { + clearLoading(); + } +} + +// ============================================================= +// Externe Direkt-Links (Entra, Intune, Members-Tab) +// ============================================================= + +const ENTRA_ICON = ``; +const MEMBERS_ICON = ``; +const TRASH_ICON = ``; +const LOCK_ICON = ``; +const PENCIL_ICON = ``; +// Microsoft Teams Icon — original aus Bootstrap Icons (microsoft-teams) +const TEAMS_ICON = ``; + +function entraUserLink(id, name) { + const url = `https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${encodeURIComponent(id)}`; + return `${ENTRA_ICON}`; +} + +function entraGroupLink(id, name) { + // Group-Overview-Blade im Intune-Portal. Wichtig: schliessendes "/menuId/" + // ist Teil des Deep-Links und darf NICHT weggelassen werden. + const url = `https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Overview/groupId/${encodeURIComponent(id)}/menuId/`; + return `${ENTRA_ICON}`; +} + +function intuneAppLink(id, name) { + const url = `https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/2/appId/${encodeURIComponent(id)}`; + return `${ENTRA_ICON}`; +} + +function membersTabLink(id, name) { + // Oeffnet die Members-Blade der Gruppe direkt im Intune-Portal (neuer Tab). + // Auch hier ist das "/menuId/" am Ende Pflicht. + const url = `https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Members/groupId/${encodeURIComponent(id)}/menuId/`; + return `${MEMBERS_ICON}`; +} + +function teamsChatLink(upn, name) { + // Microsoft-Teams Deep-Link: oeffnet IMMER den installierten Desktop-Client + // ueber den msteams:-Protocol-Handler. Web-Variante (https://) wird hier + // bewusst nicht verwendet — der User hat den Desktop-Client als gewuenschten + // Zielort definiert. + if (!upn) return ''; + const url = `msteams:/l/chat/0/0?users=${encodeURIComponent(upn)}`; + return `${TEAMS_ICON}`; +} + +// ============================================================= +// Inline Mitglieder-Browser (Gruppe aufklappen → User auswaehlen) +// ============================================================= + +async function toggleGroupExpand(groupId, groupName) { + if (State.expandedGroups.has(groupId)) { + State.expandedGroups.delete(groupId); + renderTargets(); + return; + } + // Anlegen + Loading + State.expandedGroups.set(groupId, { + members: null, + selected: new Set(), + loading: true, + error: null, + filter: '', + name: groupName, + }); + renderTargets(); + + try { + const res = await api(`/api/groups/${encodeURIComponent(groupId)}/members`); + const entry = State.expandedGroups.get(groupId); + if (!entry) return; // user has collapsed in the meantime + entry.members = (res.items || []) + .filter(m => m.UserPrincipalName) + .sort((a, b) => (a.DisplayName || '').localeCompare(b.DisplayName || '', 'de', { sensitivity: 'base' })); + entry.loading = false; + } catch (e) { + const entry = State.expandedGroups.get(groupId); + if (!entry) return; + entry.loading = false; + entry.error = e.message; + } + renderTargets(); +} + +function renderExpandedMembers(groupId, groupName) { + const entry = State.expandedGroups.get(groupId); + if (!entry) return ''; + + if (entry.loading) { + return `
    +
    +
    + Lade Mitglieder... +
    +
    `; + } + + if (entry.error) { + return `
    +
    Fehler: ${escapeHtml(entry.error)}
    +
    `; + } + + const all = entry.members || []; + const q = entry.filter.toLowerCase(); + const items = q + ? all.filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q)) + : all; + + const selectedCount = entry.selected.size; + + const filterInput = all.length > 6 + ? `` + : ''; + + const memberRows = items.length === 0 + ? `
    ${all.length === 0 ? 'Keine direkten Benutzer in dieser Gruppe.' : 'Keine Treffer.'}
    ` + : items.map(m => { + const isSel = entry.selected.has(m.Id) ? 'selected' : ''; + const checked = entry.selected.has(m.Id) ? 'checked' : ''; + return ``; + }).join(''); + + return `
    + ${filterInput} +
    + ${all.length} Mitglieder${selectedCount ? ` · ${selectedCount} ausgewählt` : ''} +
    + + · + +
    +
    +
    ${memberRows}
    +
    + +
    +
    `; +} + +function attachExpandedHandlers(list) { + // Filter-Input + list.querySelectorAll('.expand-filter').forEach(inp => { + inp.addEventListener('input', e => { + const gid = inp.dataset.groupId; + const entry = State.expandedGroups.get(gid); + if (!entry) return; + entry.filter = e.target.value; + // Nur die expand-list neu rendern, nicht alles — sonst verliert der Input den Focus + const wrapper = inp.closest('.target-row-wrap'); + if (wrapper) { + const oldList = wrapper.querySelector('.expand-list'); + const oldCount = wrapper.querySelector('.expand-count'); + const fresh = document.createElement('div'); + fresh.innerHTML = renderExpandedMembers(gid, entry.name); + const newList = fresh.querySelector('.expand-list'); + const newCount = fresh.querySelector('.expand-count'); + if (oldList && newList) oldList.replaceWith(newList); + if (oldCount && newCount) oldCount.replaceWith(newCount); + attachMemberHandlers(wrapper); + } + }); + }); + + attachMemberHandlers(list); + + // Action-Buttons (Alle / Leeren / Übernehmen) + list.querySelectorAll('[data-expand-action]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + const gid = btn.dataset.groupId; + const entry = State.expandedGroups.get(gid); + if (!entry) return; + const action = btn.dataset.expandAction; + if (action === 'all') { + const q = entry.filter.toLowerCase(); + const visible = q + ? (entry.members || []).filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q)) + : (entry.members || []); + visible.forEach(m => entry.selected.add(m.Id)); + renderTargets(); + renderSession(); + renderPinnedTargets(); + } else if (action === 'clear') { + entry.selected.clear(); + renderTargets(); + renderSession(); + renderPinnedTargets(); + } else if (action === 'apply') { + applyExpandedSelection(gid); + } + }); + }); +} + +function attachMemberHandlers(scope) { + scope.querySelectorAll('.ex-member-row').forEach(row => { + row.addEventListener('change', e => { + const gid = row.dataset.groupId; + const mid = row.dataset.memberId; + const entry = State.expandedGroups.get(gid); + if (!entry) return; + if (e.target.checked) entry.selected.add(mid); + else entry.selected.delete(mid); + row.classList.toggle('selected', e.target.checked); + // Counter & Apply-Button updaten ohne Komplettrender + const wrapper = row.closest('.target-row-wrap'); + if (wrapper) { + const cnt = wrapper.querySelector('.expand-count'); + if (cnt) cnt.innerHTML = `${(entry.members || []).length} Mitglieder${entry.selected.size ? ` · ${entry.selected.size} ausgewählt` : ''}`; + const btn = wrapper.querySelector('[data-expand-action="apply"]'); + if (btn) { + btn.disabled = entry.selected.size === 0; + btn.textContent = entry.selected.size > 0 ? `${entry.selected.size} Benutzer übernehmen` : 'Benutzer übernehmen'; + } + } + // Rechte Spalte (Empfaenger-Zaehler) und Pin-Leiste aktualisieren + renderSession(); + renderPinnedTargets(); + }); + }); +} + +function applyExpandedSelection(groupId) { + const entry = State.expandedGroups.get(groupId); + if (!entry || !entry.members) return; + const picked = entry.members.filter(m => entry.selected.has(m.Id)); + if (picked.length === 0) { + toast('Keine Mitglieder ausgewählt', 'warn'); + return; + } + + // 1) Aktuellen Modus (dept oder rpa) in den Cache schreiben — sonst geht + // die Abteilungs-Auswahl + die geladene Liste verloren. + const oldMode = State.mode; + if (oldMode !== 'user') { + State.modeCache.set(oldMode, { + targets: State.targets.slice(), + selectedIds: new Set(State.selectedTargetIds), + expandedGroups: new Map(), // expand wird gleich geschlossen + }); + } + + // 2) In User-Modus wechseln. Bestehende User-Auswahl MERGEN, nicht ueberschreiben. + State.mode = 'user'; + document.querySelectorAll('.seg').forEach(s => s.classList.toggle('active', s.dataset.mode === 'user')); + + const cachedUser = State.modeCache.get('user'); + if (cachedUser && cachedUser.targets && cachedUser.targets.length > 0) { + const existingIds = new Set(cachedUser.targets.map(t => t.Id)); + const newOnes = picked.filter(p => !existingIds.has(p.Id)); + State.targets = [...cachedUser.targets, ...newOnes]; + State.selectedTargetIds = new Set([ + ...cachedUser.selectedIds, + ...picked.map(p => p.Id), + ]); + } else { + State.targets = picked.slice(); + State.selectedTargetIds = new Set(picked.map(m => m.Id)); + } + State.membershipCache.clear(); + State.expandedGroups.clear(); + + const search = document.getElementById('targetSearch'); + search.placeholder = 'Tippe Name oder UPN...'; + document.getElementById('userSearchHint').classList.remove('hidden'); + search.value = ''; + + renderTargets(); + onTargetSelectionChange(); + toast(`${picked.length} Benutzer aus "${entry.name}" übernommen — Abteilungs-Auswahl bleibt erhalten`, 'ok'); +} + +// (Alte Modal-Logik — unbenutzt, aber harmlos:) +let mmContext = { groupId: null, groupName: null, members: [], selectedIds: new Set() }; + +async function openMembersModal(groupId, groupName) { + mmContext = { groupId, groupName, members: [], selectedIds: new Set() }; + document.getElementById('mmTitle').textContent = `Mitglieder · ${groupName}`; + document.getElementById('mmSearch').value = ''; + document.getElementById('mmInfo').textContent = '0 ausgewählt'; + document.getElementById('mmList').innerHTML = ` +
    +
    +
    +
    +
    +
    `; + openModal('modalMembers'); + + try { + const res = await api(`/api/groups/${encodeURIComponent(groupId)}/members`); + mmContext.members = (res.items || []).filter(m => m.UserPrincipalName); // nur User, keine geschachtelten Gruppen + renderMembersList(); + } catch (e) { + document.getElementById('mmList').innerHTML = emptyState({ + title: 'Fehler beim Laden', + text: e.message, + }); + } +} + +function renderMembersList() { + const q = (document.getElementById('mmSearch').value || '').toLowerCase().trim(); + const items = q + ? mmContext.members.filter(m => + (m.DisplayName || '').toLowerCase().includes(q) || + (m.UserPrincipalName || '').toLowerCase().includes(q)) + : mmContext.members; + + const list = document.getElementById('mmList'); + if (mmContext.members.length === 0) { + list.innerHTML = emptyState({ small: true, title: 'Keine Mitglieder', text: 'Diese Gruppe hat keine direkten Benutzer.' }); + return; + } + if (items.length === 0) { + list.innerHTML = emptyState({ small: true, title: 'Keine Treffer', text: 'Filter zurücksetzen.' }); + return; + } + + list.innerHTML = items.map(m => { + const checked = mmContext.selectedIds.has(m.Id) ? 'checked' : ''; + const sel = mmContext.selectedIds.has(m.Id) ? 'selected' : ''; + return ``; + }).join(''); + + list.querySelectorAll('.member-row').forEach(row => { + row.addEventListener('change', e => { + const id = row.dataset.id; + if (e.target.checked) mmContext.selectedIds.add(id); + else mmContext.selectedIds.delete(id); + row.classList.toggle('selected', e.target.checked); + document.getElementById('mmInfo').textContent = + mmContext.selectedIds.size === 0 ? 'Keine ausgewählt' : `${mmContext.selectedIds.size} ausgewählt`; + }); + }); +} + +document.getElementById('mmSearch')?.addEventListener('input', renderMembersList); + +document.getElementById('mmSelectAll')?.addEventListener('click', () => { + const q = (document.getElementById('mmSearch').value || '').toLowerCase().trim(); + const items = q + ? mmContext.members.filter(m => + (m.DisplayName || '').toLowerCase().includes(q) || + (m.UserPrincipalName || '').toLowerCase().includes(q)) + : mmContext.members; + items.forEach(m => mmContext.selectedIds.add(m.Id)); + renderMembersList(); + document.getElementById('mmInfo').textContent = `${mmContext.selectedIds.size} ausgewählt`; +}); + +document.getElementById('mmClear')?.addEventListener('click', () => { + mmContext.selectedIds.clear(); + renderMembersList(); + document.getElementById('mmInfo').textContent = 'Keine ausgewählt'; +}); + +document.getElementById('mmApply')?.addEventListener('click', () => { + if (mmContext.selectedIds.size === 0) { + toast('Keine Mitglieder ausgewählt', 'warn'); + return; + } + + // In den User-Modus wechseln und die ausgewählten Mitglieder als Targets übernehmen + State.mode = 'user'; + document.querySelectorAll('.seg').forEach(s => s.classList.toggle('active', s.dataset.mode === 'user')); + + const picked = mmContext.members.filter(m => mmContext.selectedIds.has(m.Id)); + State.targets = picked; + State.selectedTargetIds = new Set(picked.map(m => m.Id)); + State.membershipCache.clear(); + + const search = document.getElementById('targetSearch'); + search.placeholder = 'Tippe Name oder UPN...'; + document.getElementById('userSearchHint').classList.remove('hidden'); + search.value = ''; + + renderTargets(); + onTargetSelectionChange(); + closeModal('modalMembers'); + toast(`${picked.length} Benutzer aus "${mmContext.groupName}" übernommen`, 'ok'); +}); + +// ============================================================= +// Helpers +// ============================================================= + +function emptyState({ title, text, small = false, ico = null }) { + return `
    + ${ico ? `
    ${ico}
    ` : ''} + ${title ? `
    ${escapeHtml(title)}
    ` : ''} + ${text ? `
    ${escapeHtml(text)}
    ` : ''} +
    `; +} + +// ============================================================= +// Hilfe-Modal: laedt www/help.md und rendert es mit marked. +// TOC wird aus den H2-Headings auto-generiert (mit Anker-IDs). +// ============================================================= + +let helpLoaded = false; + +async function openHelpModal() { + openModal('modalHelp'); + // Version-Badge im Footer setzen (immer aktuell, auch wenn helpLoaded == true) + try { + const v = await api('/api/version'); + const el = document.getElementById('helpVersion'); + if (el) el.textContent = `Version ${v.version} · Build ${v.build}`; + } catch {} + if (helpLoaded) return; + const contentEl = document.getElementById('helpContent'); + try { + const res = await fetch('/help.md', { cache: 'no-store' }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const md = await res.text(); + // marked rendert das ganze Dokument + const html = renderMarkdown(md); + contentEl.innerHTML = html; + // H2 mit IDs versehen und TOC bauen + const tocEl = document.querySelector('#helpToc ul'); + tocEl.innerHTML = ''; + const slug = (txt) => txt.toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-'); + const seen = new Set(); + const headings = []; + const links = new Map(); // headingId -> element + contentEl.querySelectorAll('h2').forEach(h => { + let id = slug(h.textContent); + let n = 2; + while (seen.has(id)) { id = slug(h.textContent) + '-' + (n++); } + seen.add(id); + h.id = id; + headings.push(h); + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = '#' + id; + a.textContent = h.textContent; + a.addEventListener('click', e => { + e.preventDefault(); + h.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + li.appendChild(a); + tocEl.appendChild(li); + links.set(id, a); + }); + + // Aktiver TOC-Eintrag via Scroll-Position erkennen. + // IntersectionObserver mit Top-Marker (kleiner Slot oben im Viewport) — + // sobald ein H2 darin auftaucht, ist es der "aktive" Abschnitt. + const setActive = (id) => { + links.forEach((a, key) => a.classList.toggle('active', key === id)); + }; + // Default: erstes Heading aktiv + if (headings.length > 0) setActive(headings[0].id); + + // Beobachtung: scroll-Position im help-content-Container + const onScroll = () => { + const containerTop = contentEl.getBoundingClientRect().top; + let currentId = headings[0]?.id; + // Nimm das letzte Heading dessen oben-Kante schon ueber dem 1/4-Mark + // des Containers ist — wirkt natuerlicher als "schwellt rein". + const threshold = containerTop + contentEl.clientHeight * 0.25; + for (const h of headings) { + if (h.getBoundingClientRect().top <= threshold) currentId = h.id; + else break; + } + if (currentId) setActive(currentId); + }; + contentEl.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + helpLoaded = true; + } catch (e) { + contentEl.innerHTML = `
    Hilfe konnte nicht geladen werden: ${escapeHtml(e.message)}
    `; + } +} + +document.getElementById('btnHelp')?.addEventListener('click', openHelpModal); + +// ============================================================= +// Branding / Theme: Logo + benutzerdefinierte Farben +// ============================================================= + +// Default-Hex pro Konzept — wird bei "Reset" pro Farbe verwendet. +// Diese Werte sind die VOM USER vorgegebenen Defaults (kein willkuerliches +// Bootstrap-Blau mehr). Spiegelt Get-DefaultSettings im Backend. +const ThemeDefaults = { + brand: '#27a078', + accent: '#3b82f6', + required: '#cb2a7a', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + rowSelected: '#71e5c4', + detailPanel: '#f7f7f7', +}; + + +// Welche Konzept-Farbe auf welche CSS-Variablen abgebildet wird. Die soft/ +// strong/bg/border/shadow-Werte werden zur Laufzeit aus dem Hex per rgba() +// abgeleitet (Alpha-Konstanten siehe applyThemeColors). +const ThemeMap = { + brand: { main: '--brand-primary' }, + accent: { main: '--brand-accent', soft: '--accent-soft', strong: '--accent-strong', bg: '--accent-bg' }, + required: { main: '--pink', soft: '--pink-soft' }, + success: { main: '--success', soft: '--success-soft', strong: '--success-strong', bg: '--success-bg', border: '--success-border', shadow: '--success-shadow' }, + warning: { main: '--warning', soft: '--warning-soft', strong: '--warning-strong', bg: '--warning-bg', border: '--warning-border' }, + error: { main: '--error', soft: '--error-soft', strong: '--error-strong', bg: '--error-bg', border: '--error-border' }, +}; + +function hexToRgb(hex) { + if (!hex) return null; + let h = hex.replace(/^#/, ''); + if (h.length === 3) h = h.split('').map(c => c + c).join(''); + if (!/^[0-9a-f]{6}$/i.test(h)) return null; + const n = parseInt(h, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function rgbaStr(hex, alpha) { + const rgb = hexToRgb(hex); + if (!rgb) return null; + return `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`; +} + +// WCAG-Relative-Luminance — basis fuer Kontrast-Entscheidung +function relLuminance(rgb) { + const ch = (v) => { + const s = v / 255; + return s < 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * ch(rgb.r) + 0.7152 * ch(rgb.g) + 0.0722 * ch(rgb.b); +} + +// WCAG Contrast Ratio zwischen zwei Farben +function contrastRatio(rgb1, rgb2) { + const l1 = relLuminance(rgb1); + const l2 = relLuminance(rgb2); + const hi = Math.max(l1, l2); + const lo = Math.min(l1, l2); + return (hi + 0.05) / (lo + 0.05); +} + +// Liefert eine gut lesbare Vordergrund-Palette fuer den gegebenen Hintergrund. +// Pickt zwischen weiss/schwarz basierend auf WCAG-Contrast (nicht nur Luminanz- +// Schwellwert — der wuerde bei mittel-hellen Farben wie Pink danebenliegen). +// Liefert {primary, secondary, muted} jeweils mit absteigender Saettigung +// gegen den Hintergrund. +function readableForeground(bgRgb) { + if (!bgRgb) return { primary: '#111111', secondary: '#374151', muted: '#6b7280' }; + const WHITE = { r: 255, g: 255, b: 255 }; + const BLACK = { r: 17, g: 17, b: 17 }; + const crWhite = contrastRatio(bgRgb, WHITE); + const crBlack = contrastRatio(bgRgb, BLACK); + if (crWhite >= crBlack) { + // Weisser Text ist kontrastreicher -> bg ist eher dunkel + return { primary: '#ffffff', secondary: '#e7eaf0', muted: '#c9d0db' }; + } + // Schwarzer Text ist kontrastreicher -> bg ist eher hell + return { primary: '#111111', secondary: '#374151', muted: '#475569' }; +} + + +function applyThemeColors(colors) { + if (!colors) return; + const root = document.documentElement.style; + for (const key of Object.keys(ThemeMap)) { + const hex = colors[key]; + if (!hex || !hexToRgb(hex)) continue; + const vars = ThemeMap[key]; + root.setProperty(vars.main, hex); + if (vars.soft) root.setProperty(vars.soft, rgbaStr(hex, 0.12)); + if (vars.strong) root.setProperty(vars.strong, rgbaStr(hex, 0.20)); + if (vars.border) root.setProperty(vars.border, rgbaStr(hex, 0.30)); + if (vars.bg) root.setProperty(vars.bg, rgbaStr(hex, 0.06)); + if (vars.shadow) root.setProperty(vars.shadow, rgbaStr(hex, 0.35)); + } + + // Background der aufgeklappten App: picked color wird 1:1 als solid + // Hintergrund gesetzt (keine Transparenz). Text und Icons werden per + // WCAG-Kontrast automatisch in Schwarz oder Weiss gewaehlt. + // Innere Surfaces (Chips, File-Paths, Asg-Boxen) werden als SOLID-Shade + // der Picked Color berechnet (heller bei dunkler Tint, dunkler bei heller). + const rgbToHex = (rgb) => '#' + ['r','g','b'].map(c => Math.max(0, Math.min(255, Math.round(rgb[c]))).toString(16).padStart(2,'0')).join(''); + const shade = (rgb, delta) => ({ + r: Math.max(0, Math.min(255, rgb.r + Math.round(delta * 255))), + g: Math.max(0, Math.min(255, rgb.g + Math.round(delta * 255))), + b: Math.max(0, Math.min(255, rgb.b + Math.round(delta * 255))), + }); + const surfaceShades = (rgb) => { + const isDark = relLuminance(rgb) < 0.5; + const d = isDark ? 0.06 : -0.04; // Dark bg -> heller machen, Light bg -> dunkler + return { + surface: rgbToHex(shade(rgb, d)), + surfaceStrong: rgbToHex(shade(rgb, d * 2)), + border: rgbToHex(shade(rgb, d * 3)), + borderSoft: rgbToHex(shade(rgb, d * 1.5)), + }; + }; + + if (colors.rowSelected && hexToRgb(colors.rowSelected)) { + const rgb = hexToRgb(colors.rowSelected); + // Picked color wird 1:1 als Background gesetzt — keine Transparenz, kein rgba. + root.setProperty('--row-selected-border', colors.rowSelected); + root.setProperty('--row-selected-bg', colors.rowSelected); + root.setProperty('--row-selected-bg-hover', colors.rowSelected); + const fg = readableForeground(rgb); + root.setProperty('--row-selected-text', fg.primary); + root.setProperty('--row-selected-text-muted', fg.secondary); + root.setProperty('--row-selected-icon', fg.primary); + root.setProperty('--row-selected-icon-hover', fg.primary); + const so = surfaceShades(rgb); + root.setProperty('--row-selected-surface', so.surface); + root.setProperty('--row-selected-surface-strong', so.surfaceStrong); + } + if (colors.detailPanel && hexToRgb(colors.detailPanel)) { + const rgb = hexToRgb(colors.detailPanel); + root.setProperty('--app-expanded-bg', colors.detailPanel); + const fg = readableForeground(rgb); + root.setProperty('--detail-text', fg.primary); + root.setProperty('--detail-text-muted', fg.secondary); + root.setProperty('--detail-text-faint', fg.muted); + const so = surfaceShades(rgb); + root.setProperty('--detail-surface', so.surface); + root.setProperty('--detail-surface-strong', so.surfaceStrong); + root.setProperty('--detail-border', so.border); + root.setProperty('--detail-border-soft', so.borderSoft); + } +} + +// Default-Logo das immer im Projekt-Root liegt — wird verwendet, wenn der +// User kein eigenes Logo hochgeladen hat (oder seines wieder entfernt). +const DEFAULT_LOGO_FILE = 'application.png'; + +function applyBranding(branding, cacheBust) { + const logoEl = document.getElementById('brandLogo'); + if (!logoEl) return; + const file = (branding && branding.logoFile) || DEFAULT_LOGO_FILE; + // cacheBust-Fallback-Kette: explizit uebergeben -> branding.logoCacheTag -> 1 + // (Default-Logo ist statisch, einmaliges Laden reicht; uploaded Logos + // bekommen vom Backend einen mtime-Tag.) + const tag = cacheBust || (branding && branding.logoCacheTag) || '1'; + const url = `/assets/${encodeURIComponent(file)}?v=${encodeURIComponent(tag)}`; + logoEl.innerHTML = `Logo`; + logoEl.classList.add('has-custom-logo'); +} + +// ============================================================= +// Einstellungen (Tenant/Client-ID, Gruppen-Praefixe, Naming, ...) +// ============================================================= + +// Fallback-Vendor-Definitionen falls SettingsState aus irgendeinem Grund +// (Settings-Load-Race, kaputte settings.json) leer ist. Damit zeigen PMPC- +// und Robopack-Apps IMMER Source-Badge + Lock-Icons, unabhaengig von Backend. +const VENDOR_FALLBACKS = [ + { id: 'patchmypc', displayName: 'PatchMyPC', portalUrl: 'https://portal.patchmypc.com/', logoFile: 'pmpc.png', block: { delete: true, rename: true } }, + { id: 'robopack', displayName: 'Robopack', portalUrl: 'https://app.robopack.com/', logoFile: 'robopack.png', block: { delete: true, rename: true } }, +]; + +const SettingsState = { + current: null, // zuletzt geladene Settings vom Server + filePath: null, + // Vendor-Indizes — werden bei jedem Settings-Load via rebuildVendorIndexes() + // neu aufgebaut, damit Render + Filter ohne wiederholten Lookup laufen. + // Initial mit Fallbacks befuellt, damit der erste Render schon korrekt rendert. + vendors: VENDOR_FALLBACKS.slice(), + vendorsById: Object.fromEntries(VENDOR_FALLBACKS.map(v => [v.id, v])), + vendorsByDisplayName: Object.fromEntries(VENDOR_FALLBACKS.map(v => [v.displayName, v])), +}; + +function rebuildVendorIndexes(settings) { + let list = (settings && Array.isArray(settings.vendors)) ? settings.vendors : []; + // Fehlende Standard-Vendoren immer mit reinmischen — verhindert dass eine + // unvollstaendige settings.json PMPC/Robopack aus dem UI verschwinden laesst. + for (const fb of VENDOR_FALLBACKS) { + if (!list.some(v => v && v.id === fb.id)) list = list.concat([fb]); + } + SettingsState.vendors = list; + SettingsState.vendorsById = {}; + SettingsState.vendorsByDisplayName = {}; + for (const v of list) { + if (v && v.id) SettingsState.vendorsById[v.id] = v; + if (v && v.displayName) SettingsState.vendorsByDisplayName[v.displayName] = v; + } + rebuildVendorFilterOptions(); +} + +function rebuildVendorFilterOptions() { + // Filter-Select hat statische Optionen fuer "Alle/Intune"; Vendor-Optionen + // werden hier nachgereicht. Vorhandene srcVendor:-Options vorher entsorgen. + const sel = document.getElementById('appFilter'); + if (!sel) return; + // Bestehende dynamische Eintraege entfernen + Array.from(sel.querySelectorAll('option[data-vendor-option]')).forEach(o => o.remove()); + // "Nur Intune" als Anker — Vendor-Eintraege werden direkt danach eingefuegt + const intuneOpt = Array.from(sel.options).find(o => o.value === 'srcIntune'); + const insertAfter = intuneOpt || sel.options[sel.options.length - 1]; + for (const v of SettingsState.vendors) { + if (!v || !v.id || !v.displayName) continue; + const opt = document.createElement('option'); + opt.value = `srcVendor:${v.id}`; + opt.textContent = `Nur ${v.displayName}-Apps`; + opt.dataset.vendorOption = '1'; + insertAfter.insertAdjacentElement('afterend', opt); + } +} + +function rebuildCategoryFilterOptions() { + // Baut die Optionen des Kategorie-Filters aus den tatsaechlich in den + // geladenen Apps vorkommenden Kategorien. Auswahl bleibt erhalten falls + // die Kategorie noch existiert. + const sel = document.getElementById('appCategoryFilter'); + if (!sel) return; + const prev = sel.value; + + // Distinkte Kategorien sammeln (alphabetisch, de-DE) + const set = new Set(); + let anyWithout = false; + for (const a of State.apps) { + if (Array.isArray(a.Categories) && a.Categories.length > 0) { + for (const c of a.Categories) { if (c) set.add(String(c)); } + } else { + anyWithout = true; + } + } + const cats = Array.from(set).sort((x, y) => x.localeCompare(y, 'de-DE')); + + // Neu aufbauen: "Alle Kategorien" fix, dann optional "Ohne Kategorie", dann die Namen + sel.innerHTML = ''; + const optAll = document.createElement('option'); + optAll.value = ''; + optAll.textContent = 'Alle Kategorien'; + sel.appendChild(optAll); + + if (anyWithout) { + const optNone = document.createElement('option'); + optNone.value = '__none__'; + optNone.textContent = 'Ohne Kategorie (Default)'; + sel.appendChild(optNone); + } + + for (const c of cats) { + const opt = document.createElement('option'); + opt.value = c; + opt.textContent = c; + sel.appendChild(opt); + } + + // Vorherige Auswahl wiederherstellen wenn noch gueltig + if (prev && (prev === '__none__' || cats.includes(prev))) { + sel.value = prev; + } else { + sel.value = ''; + State.appFilter.category = ''; + } +} + +function isSetupIncomplete(s) { + if (!s) return true; + const c = s.connection || {}; + const d = s.departments || {}; + const tenant = (c.tenantId || '').trim(); + const client = (c.clientId || '').trim(); + // Praefixe: 'prefixes'-Array bevorzugt, sonst alter Single-String 'prefix'. + // Setup ist komplett, wenn mind. ein nicht-leerer Praefix (>= 2 Zeichen) existiert. + let hasPrefix = false; + if (Array.isArray(d.prefixes)) { + for (const p of d.prefixes) { if (p && String(p).trim().length >= 2) { hasPrefix = true; break; } } + } + if (!hasPrefix && d.prefix && String(d.prefix).trim().length >= 2) { hasPrefix = true; } + return !tenant || !client || !hasPrefix; +} + +function applySetupNeededUI(needed) { + // Setup-Banner im Modal + Hinweis im Onboarding-Panel + Connect-Button disablen + const banner = document.getElementById('setupBanner'); + if (banner) banner.classList.toggle('hidden', !needed); + const hint = document.getElementById('onboardingSetupHint'); + if (hint) hint.classList.toggle('hidden', !needed); + const btn = document.getElementById('btnConnect'); + if (btn) { + btn.disabled = !!needed; + btn.title = needed ? 'Bitte zuerst in Einstellungen konfigurieren.' : ''; + } +} + +async function openSettingsModal() { + // Modal sofort oeffnen, Inhalt nachladen + document.getElementById('setFilePath').textContent = SettingsState.filePath || 'wird geladen…'; + // Test-Result-Box bei jedem Oeffnen leeren — stale Ergebnisse vermeiden. + const trBox = document.getElementById('settingsTestResult'); + if (trBox) trBox.innerHTML = ''; + openModal('modalSettings'); + try { + const res = await api('/api/settings'); + SettingsState.current = res.settings; + SettingsState.filePath = res.path; + rebuildVendorIndexes(res.settings); + fillSettingsForm(res.settings); + document.getElementById('setFilePath').textContent = res.path || '(unbekannt)'; + const banner = document.getElementById('setupBanner'); + if (banner) banner.classList.toggle('hidden', !isSetupIncomplete(res.settings)); + } catch (e) { + toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'err'); + } +} + +async function onSettingsTest() { + const box = document.getElementById('settingsTestResult'); + const btn = document.getElementById('btnSettingsTest'); + if (!box) return; + // Aktuellen Form-State testen (nicht den gespeicherten). + const payload = readSettingsForm(); + box.innerHTML = '
    Pruefe Werte gegen Microsoft Graph…
    '; + if (btn) btn.disabled = true; + try { + const res = await api('/api/settings/test', { method: 'POST', body: payload }); + box.innerHTML = renderSettingsTestResult(res); + } catch (e) { + box.innerHTML = `
    Test fehlgeschlagen: ${escapeHtml(e.message || String(e))}
    `; + } finally { + if (btn) btn.disabled = false; + } +} + +function renderSettingsTestResult(res) { + const lines = []; + const ok = 'OK'; + const fail = 'FAIL'; + const conn = res.connection || {}; + if (conn.ok) { + lines.push(`
    ${ok} Verbindung: ${escapeHtml(conn.account || '')} (Tenant ${escapeHtml(conn.tenantId || '')})
    `); + } else { + const why = conn.reason === 'not_connected' ? 'Bitte zuerst per Connect-Button verbinden.' : (conn.message || 'unbekannter Grund'); + lines.push(`
    ${fail} Verbindung: ${escapeHtml(why)}
    `); + return lines.join(''); + } + const d = res.departments || {}; + if (d.reason === 'not_configured') { + lines.push(`
    ${fail} Abteilungs-Praefix(e) nicht gesetzt.
    `); + } else if (Array.isArray(d.perPrefix) && d.perPrefix.length > 0) { + // Neue Form: eine Zeile pro Praefix + lines.push(`
    ${d.ok ? ok : fail} Abteilungs-Gruppen — Total: ${d.totalCount || 0}
    `); + for (const pp of d.perPrefix) { + if (pp.ok) { + const sample = pp.sample && pp.sample.length ? ` (z.B. ${pp.sample.map(escapeHtml).join(', ')})` : ''; + const icon = pp.count > 0 ? ok : '0'; + const cls = pp.count > 0 ? '' : 'set-test-warn'; + lines.push(`
    ${icon} ${escapeHtml(pp.prefix)}: ${pp.count} Treffer${sample}
    `); + } else { + lines.push(`
    ${fail} ${escapeHtml(pp.prefix)}: ${escapeHtml(pp.error || '')}
    `); + } + } + } else if (d.ok) { + // Backward-compat: alte Single-Prefix-Response + const sample = d.sample && d.sample.length ? ` (z.B. ${d.sample.map(escapeHtml).join(', ')})` : ''; + lines.push(`
    ${ok} Abteilungs-Gruppen: ${d.count} Treffer fuer ${escapeHtml(d.prefix)}${sample}
    `); + } else { + lines.push(`
    ${fail} Abteilungs-Gruppen-Lookup fehlgeschlagen: ${escapeHtml(d.error || '')}
    `); + } + const r = res.rpa || {}; + if (r.ok) { + lines.push(`
    ${ok} RPA-Gruppen: ${(r.found || []).length} von ${r.expected} gefunden
    `); + } else if (r.reason === 'not_configured') { + lines.push(`
    ${fail} Keine RPA-Gruppen konfiguriert (optional — der RPA-Tab bleibt dann leer).
    `); + } else { + const miss = (r.missing || []).map(escapeHtml).join(', '); + lines.push(`
    ${fail} RPA-Gruppen: ${(r.found || []).length} von ${r.expected} gefunden. Fehlt: ${miss}
    `); + } + const u = res.userSearch || {}; + if (u.ok) { + lines.push(`
    ${ok} Benutzer-Suche: ok (${(u.fields || []).join(', ')})
    `); + } else if (u.reason === 'no_fields') { + lines.push(`
    ${fail} Keine User-Such-Felder gewaehlt.
    `); + } else { + lines.push(`
    ${fail} Benutzer-Suche fehlgeschlagen: ${escapeHtml(u.error || '')}
    `); + } + const ven = res.vendors || {}; + if (Array.isArray(ven.counts) && ven.counts.length > 0) { + for (const c of ven.counts) { + const icon = c.count > 0 ? ok : '0'; + const cls = c.count > 0 ? '' : 'set-test-warn'; + lines.push(`
    ${icon} Vendor ${escapeHtml(c.displayName)}: ${c.count} Apps (Regel: ${escapeHtml(c.detection)})
    `); + } + if (ven.hint) { + lines.push(`
    Hinweis: ${escapeHtml(ven.hint)}
    `); + } + } + return lines.join(''); +} + +function fillSettingsForm(s) { + const c = s.connection || {}; + document.getElementById('setTenantId').value = c.tenantId || ''; + document.getElementById('setClientId').value = c.clientId || ''; + document.getElementById('setScopes').value = (c.scopes || []).join('\n'); + + // Backward-compat: lese 'prefixes' (Array) bevorzugt, falle sonst auf + // alten Single-String 'prefix' zurueck. Beide werden in die Textarea + // gemerged (gleiche Logik wie Get-DepartmentPrefixes im Backend). + (function () { + const d = s.departments || {}; + const list = []; + if (Array.isArray(d.prefixes)) { for (const p of d.prefixes) if (p) list.push(String(p)); } + if (d.prefix) list.push(String(d.prefix)); + const seen = {}; const out = []; + for (const raw of list) { + const t = raw.trim(); if (!t) continue; + const k = t.toLowerCase(); + if (seen[k]) continue; seen[k] = true; out.push(t); + } + document.getElementById('setDeptPrefixes').value = out.join('\n'); + })(); + document.getElementById('setRpaGroups').value = ((s.rpa && s.rpa.groupNames) || []).join('\n'); + + const fields = (s.userSearch && s.userSearch.fields) || []; + document.getElementById('setUsfDisplayName').checked = fields.includes('displayName'); + document.getElementById('setUsfUpn').checked = fields.includes('userPrincipalName'); + document.getElementById('setUsfMail').checked = fields.includes('mail'); + document.getElementById('setUsfDepartment').checked = fields.includes('department'); + + const r = s.requiredGroupNaming || {}; + document.getElementById('setReqPrefix').value = r.prefix || ''; + document.getElementById('setReqSuffix').value = r.suffix || ''; + updateReqPreview(); + const av = s.availableGroupNaming || {}; + document.getElementById('setAvailPrefix').value = av.prefix || ''; + document.getElementById('setAvailSuffix').value = av.suffix || ''; + updateAvailPreview(); + + // Branding-Logo Preview + fillLogoPreview(s.branding); + + // Farben + const cols = (s.theme && s.theme.colors) || {}; + for (const key of Object.keys(ThemeDefaults)) { + const hex = cols[key] || ThemeDefaults[key]; + setColorInputs(key, hex); + } +} + +function setColorInputs(key, hex) { + const cap = key[0].toUpperCase() + key.slice(1); + const picker = document.getElementById(`setCol${cap}`); + const text = document.getElementById(`setCol${cap}Hex`); + const swatch = document.querySelector(`.color-swatch[data-swatch="${key}"]`); + if (picker) picker.value = hex; + if (text) text.value = hex; + if (swatch) swatch.style.background = hex; +} + +function fillLogoPreview(branding) { + const userFile = branding && branding.logoFile; + const file = userFile || DEFAULT_LOGO_FILE; + const preview = document.getElementById('logoPreview'); + const removeBtn = document.getElementById('btnLogoRemove'); + if (!preview) return; + const tag = (branding && branding.logoCacheTag) || '1'; + const url = `/assets/${encodeURIComponent(file)}?v=${encodeURIComponent(tag)}`; + preview.innerHTML = `Logo`; + // "Entfernen"-Button nur wenn ein eigenes Logo aktiv ist (Default-Logo + // application.png kann nicht entfernt werden — ist die Firmen-Vorgabe). + if (removeBtn) removeBtn.hidden = !userFile; +} + +function readSettingsForm() { + const splitLines = (txt) => (txt || '') + .split(/\r?\n/).map(s => s.trim()).filter(Boolean); + const fields = []; + if (document.getElementById('setUsfDisplayName').checked) fields.push('displayName'); + if (document.getElementById('setUsfUpn').checked) fields.push('userPrincipalName'); + if (document.getElementById('setUsfMail').checked) fields.push('mail'); + if (document.getElementById('setUsfDepartment').checked) fields.push('department'); + + return { + connection: { + tenantId: document.getElementById('setTenantId').value.trim(), + clientId: document.getElementById('setClientId').value.trim(), + scopes: splitLines(document.getElementById('setScopes').value), + }, + departments: { + // Neuer Listen-Form: Praefixe aus Textarea. Alten Single-String 'prefix' + // beim Save auf leer setzen, damit nicht beide Quellen divergieren — + // das Backend liest ab jetzt nur noch aus 'prefixes'. + prefixes: splitLines(document.getElementById('setDeptPrefixes').value), + prefix: '', + }, + rpa: { + groupNames: splitLines(document.getElementById('setRpaGroups').value), + }, + userSearch: { + fields, + }, + requiredGroupNaming: { + prefix: document.getElementById('setReqPrefix').value, + suffix: document.getElementById('setReqSuffix').value, + }, + availableGroupNaming: { + prefix: document.getElementById('setAvailPrefix').value, + suffix: document.getElementById('setAvailSuffix').value, + }, + theme: { + colors: { + brand: document.getElementById('setColBrandHex').value.trim() || ThemeDefaults.brand, + accent: document.getElementById('setColAccentHex').value.trim() || ThemeDefaults.accent, + required: document.getElementById('setColRequiredHex').value.trim() || ThemeDefaults.required, + success: document.getElementById('setColSuccessHex').value.trim() || ThemeDefaults.success, + warning: document.getElementById('setColWarningHex').value.trim() || ThemeDefaults.warning, + error: document.getElementById('setColErrorHex').value.trim() || ThemeDefaults.error, + rowSelected: document.getElementById('setColRowSelectedHex').value.trim() || ThemeDefaults.rowSelected, + detailPanel: document.getElementById('setColDetailPanelHex').value.trim() || ThemeDefaults.detailPanel, + }, + }, + }; +} + +function updateReqPreview() { + const p = document.getElementById('setReqPrefix').value || ''; + const s = document.getElementById('setReqSuffix').value || ''; + const ex = (p + 'beispiel-app' + s).toLowerCase(); + document.getElementById('setReqPreview').textContent = ex; +} + +function updateAvailPreview() { + const p = document.getElementById('setAvailPrefix').value || ''; + const s = document.getElementById('setAvailSuffix').value || ''; + const ex = (p + 'beispiel-app' + s).toLowerCase(); + document.getElementById('setAvailPreview').textContent = ex; +} + +async function saveSettings() { + const payload = readSettingsForm(); + setLoading('Speichere Einstellungen...'); + try { + const res = await api('/api/settings', { method: 'PUT', body: payload }); + SettingsState.current = res.settings; + // Theme + Branding sofort live anwenden (kein Reload noetig) + if (res.settings) { + applyThemeColors(res.settings.theme && res.settings.theme.colors); + applyBranding(res.settings.branding); + rebuildVendorIndexes(res.settings); + applySetupNeededUI(isSetupIncomplete(res.settings)); + } + closeModal('modalSettings'); + if (res.disconnected) { + toast('Einstellungen gespeichert — Connection-Werte geändert, du wurdest abgemeldet.', 'warn', 'Disconnect'); + State.connected = false; + State.account = null; + // Komplettes Frontend-State neu starten + State.targets = []; + State.selectedTargetIds = new Set(); + State.apps = []; + State.session = []; + State.membershipCache.clear(); + State.expandedApps.clear(); + renderConnection(); + renderTargets(); + renderApps(); + renderSession(); + renderExisting(); + updateStepper(); + await refreshStatus(); + } else { + toast('Einstellungen gespeichert.', 'ok'); + // Nur was sich wirklich geaendert hat neu laden — Logo/Farben/Naming + // brechen keine Backend-Caches. + const changed = res.changed || {}; + if (changed.departments) { + State.loadedModes.delete('dept'); + if (State.modeCache.has('dept')) State.modeCache.delete('dept'); + } + if (changed.rpa) { + State.loadedModes.delete('rpa'); + if (State.modeCache.has('rpa')) State.modeCache.delete('rpa'); + } + if (State.connected && ((changed.departments && State.mode === 'dept') || (changed.rpa && State.mode === 'rpa'))) { + await loadTargets(); + } + } + } catch (e) { + toast(e.message, 'err', 'Speichern fehlgeschlagen'); + } finally { + clearLoading(); + } +} + +async function resetSettings() { + if (!window.confirm('Alle Einstellungen auf Standardwerte zurücksetzen?\n\nDu wirst dabei von Microsoft Graph abgemeldet.')) return; + setLoading('Setze Einstellungen zurück...'); + try { + const res = await api('/api/settings/reset', { method: 'POST' }); + SettingsState.current = res.settings; + if (res.settings) { + applyThemeColors(res.settings.theme && res.settings.theme.colors); + applyBranding(res.settings.branding); + rebuildVendorIndexes(res.settings); + } + fillSettingsForm(res.settings); + applySetupNeededUI(isSetupIncomplete(res.settings)); + State.connected = false; + State.account = null; + renderConnection(); + await refreshStatus(); + toast('Einstellungen zurückgesetzt.', 'ok'); + } catch (e) { + toast(e.message, 'err', 'Reset fehlgeschlagen'); + } finally { + clearLoading(); + } +} + +// Verkabelung +document.getElementById('btnSettings')?.addEventListener('click', openSettingsModal); +document.getElementById('btnSettingsSave')?.addEventListener('click', saveSettings); +document.getElementById('btnSettingsReset')?.addEventListener('click', resetSettings); +document.getElementById('btnSettingsTest')?.addEventListener('click', onSettingsTest); +document.getElementById('onboardingOpenSettings')?.addEventListener('click', e => { + e.preventDefault(); + openSettingsModal(); +}); +document.getElementById('setReqPrefix')?.addEventListener('input', updateReqPreview); +document.getElementById('setReqSuffix')?.addEventListener('input', updateReqPreview); +document.getElementById('setAvailPrefix')?.addEventListener('input', updateAvailPreview); +document.getElementById('setAvailSuffix')?.addEventListener('input', updateAvailPreview); + +document.getElementById('raSubmit')?.addEventListener('click', submitRenameApp); +document.getElementById('raNewName')?.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); submitRenameApp(); } +}); + +// Setup-Datei aktualisieren: Submit + Bestaetigungs-Checkbox steuert den Button +document.getElementById('ucSubmit')?.addEventListener('click', submitUpdateContent); +document.getElementById('ucConfirm')?.addEventListener('change', e => { + const btn = document.getElementById('ucSubmit'); + if (btn) btn.disabled = !e.target.checked; +}); +document.getElementById('ucBrowse')?.addEventListener('click', async () => { + const btn = document.getElementById('ucBrowse'); + const errEl = document.getElementById('ucError'); + errEl.textContent = ''; + btn.disabled = true; + const prevLabel = btn.textContent; + btn.textContent = 'Dialog offen…'; + try { + // Datei-Dialog auf dem Server-Rechner (Phase 1: .intunewin vorfiltern) + const res = await api('/api/pickfile', { + method: 'POST', + body: { filter: 'Intune-Pakete (*.intunewin)|*.intunewin|Alle Dateien (*.*)|*.*', title: 'Setup-Datei auswählen' }, + timeoutMs: 600000, + }); + if (res.path) document.getElementById('ucFilePath').value = res.path; + } catch (e) { + errEl.textContent = 'Datei-Dialog fehlgeschlagen: ' + e.message; + } finally { + btn.disabled = false; + btn.textContent = prevLabel; + } +}); + +// --- Branding: Logo-Upload --- +document.getElementById('btnLogoPick')?.addEventListener('click', () => { + document.getElementById('logoFile').click(); +}); +document.getElementById('logoFile')?.addEventListener('change', async e => { + const file = e.target.files && e.target.files[0]; + e.target.value = ''; // gleiche Datei nochmal auswaehlbar machen + if (!file) return; + const maxBytes = 2 * 1024 * 1024; + if (file.size > maxBytes) { + toast(`Datei zu groß (${Math.round(file.size/1024)} KB, max. 2 MB)`, 'err'); + return; + } + setLoading('Lade Logo hoch...'); + try { + const dataUrl = await new Promise((res, rej) => { + const r = new FileReader(); + r.onload = () => res(r.result); + r.onerror = () => rej(new Error('Datei konnte nicht gelesen werden.')); + r.readAsDataURL(file); + }); + const m = String(dataUrl).match(/^data:([^;]+);base64,(.+)$/); + if (!m) throw new Error('Unerwartetes Datei-Format.'); + const res = await api('/api/settings/logo', { + method: 'POST', + body: { name: file.name, mime: m[1], dataBase64: m[2] }, + }); + if (SettingsState.current) { + if (!SettingsState.current.branding) SettingsState.current.branding = {}; + SettingsState.current.branding.logoFile = res.logoFile; + SettingsState.current.branding.logoCacheTag = res.cacheTag; + } + applyBranding({ logoFile: res.logoFile, logoCacheTag: res.cacheTag }); + fillLogoPreview({ logoFile: res.logoFile, logoCacheTag: res.cacheTag }); + toast(`Logo hochgeladen (${res.sizeKb} KB)`, 'ok'); + } catch (e) { + toast(e.message, 'err', 'Logo-Upload fehlgeschlagen'); + } finally { + clearLoading(); + } +}); +document.getElementById('btnLogoRemove')?.addEventListener('click', async () => { + if (!window.confirm('Eigenes Logo entfernen?')) return; + setLoading('Entferne Logo...'); + try { + await api('/api/settings/logo', { method: 'DELETE' }); + if (SettingsState.current && SettingsState.current.branding) { + SettingsState.current.branding.logoFile = null; + } + applyBranding({ logoFile: null }); + fillLogoPreview({ logoFile: null }); + toast('Logo entfernt.', 'ok'); + } catch (e) { + toast(e.message, 'err', 'Entfernen fehlgeschlagen'); + } finally { + clearLoading(); + } +}); + +// --- Farben: Picker <-> Hex-Input synchronisieren + Live-Preview-Swatch --- +function bindColorPair(key) { + const cap = key[0].toUpperCase() + key.slice(1); + const picker = document.getElementById(`setCol${cap}`); + const text = document.getElementById(`setCol${cap}Hex`); + const swatch = document.querySelector(`.color-swatch[data-swatch="${key}"]`); + if (!picker || !text) return; + const sync = (hex) => { + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return false; + picker.value = hex.toLowerCase(); + text.value = hex.toLowerCase(); + if (swatch) swatch.style.background = hex; + return true; + }; + picker.addEventListener('input', () => sync(picker.value)); + text.addEventListener('input', () => { + const v = text.value.trim(); + if (v.length === 7 && sync(v)) { /* ok */ } + else if (swatch) swatch.style.background = v || ThemeDefaults[key]; + }); +} +['brand','accent','required','success','warning','error','rowSelected','detailPanel'].forEach(bindColorPair); + +document.querySelectorAll('[data-color-reset]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.colorReset; + setColorInputs(key, ThemeDefaults[key]); + }); +}); + +// "Standardfarben wiederherstellen" — setzt ALLE Picker auf die Vorgabe und +// wendet sie sofort auf die UI an (Live-Vorschau ohne Speichern). User klickt +// dann selbst "Speichern" wenn er die Defaults persistieren will. +document.getElementById('btnRestoreColors')?.addEventListener('click', () => { + for (const key of Object.keys(ThemeDefaults)) { + setColorInputs(key, ThemeDefaults[key]); + } + applyThemeColors(Object.assign({}, ThemeDefaults)); + toast('Farben auf Standard zurückgesetzt — "Speichern" um zu persistieren.', 'ok'); +}); + +// ============================================================= +// Theme-Toggle +// ============================================================= + +(function setupTheme() { + const btn = document.getElementById('btnThemeToggle'); + if (!btn) return; + const sync = () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + btn.setAttribute('aria-checked', isDark ? 'true' : 'false'); + btn.title = isDark ? 'Auf Light Mode wechseln' : 'Auf Dark Mode wechseln'; + }; + btn.addEventListener('click', () => { + const cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; + const next = cur === 'dark' ? 'light' : 'dark'; + // Alle CSS-Transitions waehrend des Wechsels ausschalten — sonst animieren + // hunderte Elemente gleichzeitig ihre background/color/border-Aenderungen + // und alles ruckelt. + document.documentElement.classList.add('theme-switching'); + document.documentElement.setAttribute('data-theme', next); + try { localStorage.setItem('iam-theme', next); } catch {} + sync(); + // Im naechsten Frame Transitions wieder aktivieren + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.documentElement.classList.remove('theme-switching'); + }); + }); + }); + sync(); +})(); + +// ============================================================= +// Init +// ============================================================= + +(async function init() { + renderConnection(); + renderTargets(); + renderApps(); + renderSession(); + renderExisting(); + // Branding + Theme so frueh wie moeglich anwenden, damit nichts mit den + // Default-Farben kurz aufblitzt. Connection-Status danach. + let setupNeeded = false; + try { + const res = await api('/api/settings'); + SettingsState.current = res.settings; + SettingsState.filePath = res.path; + applyThemeColors(res.settings && res.settings.theme && res.settings.theme.colors); + applyBranding(res.settings && res.settings.branding); + rebuildVendorIndexes(res.settings); + setupNeeded = isSetupIncomplete(res.settings); + applySetupNeededUI(setupNeeded); + } catch (e) { + console.warn('Settings konnten beim Init nicht geladen werden:', e.message); + } + await refreshStatus(); + if (State.connected) autoLoadAfterConnect(); + // First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen. + if (setupNeeded) { + setTimeout(() => openSettingsModal(), 200); + } +})(); diff --git a/Intune/Intune-App-Manager-Web/www/help.md b/Intune/Intune-App-Manager-Web/www/help.md new file mode 100644 index 0000000..d5b20ae --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/help.md @@ -0,0 +1,341 @@ +# Intune App-Manager — Hilfe + +Web-Frontend für Microsoft Intune. Lädt Apps und Gruppen aus deinem Tenant über Microsoft Graph und macht das Verwalten von Zuweisungen schneller als im Intune-Portal selbst. + +--- + +## Schnellstart in 5 Schritten + +1. **Verbinden** — beim ersten Start öffnet sich der Onboarding-Screen. *Anmeldung starten* klicken → Microsoft-Anmeldefenster bestätigen. +2. **Empfänger wählen** — linke Spalte. Wechsle zwischen *Abteilung*, *RPA* oder *Benutzer* und setze die Haken. +3. **Apps laden** — falls noch nicht geschehen, klicke das *Refresh*-Icon rechts oben in der Apps-Spalte. +4. **Zuweisung sammeln** — bei einer App auf den **Available**- oder **Required**-Button klicken. Die ausgewählten Empfänger landen in der Session (rechte Spalte). +5. **Ausführen** — rechts unten *Zuweisungen ausführen*. Ein HTML-Report öffnet sich automatisch. + +--- + +## Apps zuweisen — der Hauptworkflow + +Das ist der zentrale Anwendungsfall des Tools. Du nimmst eine Liste von **Empfängern** (Abteilungen, RPA-Gruppen oder Einzel-User), suchst eine oder mehrere **Apps** und fügst die Empfänger den **bereits an der App zugewiesenen Gruppen** als Mitglieder hinzu. + +### Das mentale Modell + +Das Tool ändert **nicht die App-Zuweisungen selbst** — die Liste der Available/Required-Gruppen einer App bleibt, was sie ist. Stattdessen ändert es die **Mitgliedschaften in genau diesen Gruppen**. Beispiel: + +> Beispiel mit Platzhaltern. Die Präfixe `` und `intune-win-app-` sind in den Einstellungen konfigurierbar. + +- App *Beispiel-App* hat die Required-Gruppe `intune-win-app--required` zugewiesen. +- Du wählst links die Abteilung `-team-controlling` als Empfänger. +- Klick auf die *Required*-Pille der App → die Operation `Abteilung als Mitglied zur Required-Gruppe hinzufügen` wandert in die Session. +- Beim Ausführen wird `-team-controlling` als Mitglied in `intune-win-app--required` aufgenommen. +- Intune ziehst dann beim nächsten Sync alle Mitglieder dieser Gruppe als Required-Targets der App. + +### Schritt-für-Schritt + +1. **Empfänger markieren (linke Spalte).** Du kannst Modi mischen — z. B. zwei Abteilungen *plus* drei Einzel-Benutzer gleichzeitig auswählen. Die *Pinned-Targets*-Leiste oben zeigt dir alles, was gerade aktiv ist. +2. **Apps öffnen (mittlere Spalte).** Filter benutzen oder Suchfeld. Du musst die Apps nicht einzeln durchgehen — du kannst nacheinander bei mehreren Apps Pillen klicken, alle Operationen sammeln und am Ende einmal ausführen. +3. **Available- oder Required-Button klicken.** Was passiert hängt vom Inhalt ab: + - **App hat genau eine Available/Required-Gruppe**: Die ausgewählten Empfänger werden direkt zur Session als „diese Gruppe + dieser Intent" hinzugefügt. + - **App hat mehrere Gruppen** (Count-Badge auf dem Button): Ein Picker öffnet sich und du wählst gezielt eine Gruppe aus der Liste. + - **App hat keine Gruppe** dieses Typs: Button ist deaktiviert. +4. **Status prüfen.** Die Buttons zeigen dir farblich an, wie der aktuelle Stand ist (siehe nächster Abschnitt). +5. **Bei mehreren Apps wiederholen.** Die Session sammelt alles. Wenn du dieselbe Pille nochmal klickst, wird der Vorgang aus der Session **wieder entfernt** (Toggle-Verhalten). +6. **Ausführen.** Rechts unten *Zuweisungen ausführen*. Das Tool durchläuft jede gesammelte Operation und schickt einen Member-Add an die Microsoft-Graph-API. Der HTML-Report listet Erfolge und Fehler je App. + +### Bedeutung der Pillen-Farben + +Die Buttons *Available* und *Required* an jeder App-Zeile signalisieren über Farbe und Indikator den Zustand **bezogen auf die gerade ausgewählten Empfänger**: + +| Optik | Bedeutung | +|---|---| +| Heller Akzent-Rahmen, neutral | Standard-Zustand. Keine ausgewählten Empfänger oder noch nicht geprüft. | +| **Grüner Rahmen + grünes Häkchen** in der Zeile | **Alle** ausgewählten Empfänger sind bereits Mitglied einer der zugewiesenen Gruppen dieses Typs. Klick erzeugt evtl. Dubletten — meist nicht nötig. | +| **Gelber Rahmen + halbes Häkchen** | **Teilweise**: ein Teil der Empfänger ist schon Mitglied, der Rest nicht. Klick füllt die Lücken. | +| **Schwarz gefüllt + Count-Badge** (z. B. „3") | **Aktiv in der Session**: Operation für diese App ist schon gesammelt. Anzahl = wie viele Operationen für diese App in der Session liegen. Erneuter Klick entfernt sie wieder. | +| Italic Schrift, ausgegraut | **Native Zuweisung** (All Users / All Devices) ist die einzige Option — Mitgliedschaft kann nicht geändert werden, weil keine Gruppe existiert. | + +### Picker bei mehreren Gruppen + +Wenn eine App mehr als eine Available- oder Required-Gruppe hat, öffnet der Klick einen kleinen Picker: + +- Liste aller Gruppen dieses Typs +- Pro Gruppe wird der **Member-Status** (Mitglied / Partial mit n/m-Anzeige) angezeigt, sofern Empfänger ausgewählt sind +- Klick auf eine Gruppe = zur Session hinzufügen oder entfernen (Toggle) +- Pro Gruppe zusätzlich die *Aktions-Icons* (Intune-Portal-Link, Mitglieder, Zuweisung löschen) + +### Die Session (rechte Spalte) + +Die rechte Spalte ist deine **Vorschau aller noch nicht ausgeführten Vorgänge**. Pro Session-Eintrag siehst du: + +- App-Name + Gruppen-Name +- Empfänger-Tag (genau **ein** Empfänger pro Eintrag — bei mehreren Empfängern entstehen mehrere Einträge) +- A/R-Badge (Available oder Required) +- X-Button um den Eintrag wieder rauszunehmen + +Darunter eine Zusammenfassung mit der **Gesamtzahl der Vorgänge** — der Apply-Button zeigt diese Zahl mit an. + +### Ausführen und Report + +Klick auf *Zuweisungen ausführen* → Bestätigungs-Dialog mit Counter → echte Ausführung: + +- Für jeden Session-Eintrag wird ein Member-Add ausgeführt +- Native Targets (`ALL_USERS`/`ALL_DEVICES`) werden übersprungen — sie haben keine Gruppen-Mitgliedschaft +- Bereits-Mitglied-Fälle werden als „OK (bereits Mitglied)" geloggt — kein Fehler +- Echte Fehler (403 Berechtigung, 404 Gruppe nicht gefunden, …) werden mit Details aufgeführt + +Nach Abschluss öffnet sich automatisch ein **HTML-Report** mit: + +- Erfolgs-/Fehler-/Skipped-Counter + Erfolgsquote +- Empfänger-Chips (Abteilungen, Benutzer) +- Zuweisungs-Chips (App + Gruppe) +- Detail-Tabelle nach App gruppiert — Apps mit Fehlern sind aufgeklappt + +Reports werden in `%TEMP%\IntuneAppManager-Reports\` abgelegt und können später erneut geöffnet werden. + +### Wann sollte ich was nutzen? + +| Du willst… | Workflow | +|---|---| +| 50 User aus einer neuen Abteilung in 10 Apps mitnehmen | Empfänger = Abteilung wählen, durch 10 Apps gehen, je 1 Required- oder Available-Pille klicken, ausführen. | +| Eine neue App nur für 3 Test-Benutzer ausrollen | User-Modus, 3 User markieren. Bei der App entweder eine bestehende Available-Gruppe wählen oder *+ → Available: Neue Gruppe* erzeugen. | +| Eine bestehende Gruppen-Zuweisung einer App entfernen | App aufklappen → *Zuweisungen*-Sektion → Mülleimer-Icon neben der Gruppe. | +| Eine zugewiesene Gruppe in mehreren Apps prüfen | Bei einer App auf die Pille klicken → Picker → Gruppe aussehen → Mitglieder-Icon → Intune-Portal Mitglieder-Ansicht. Oder via Filter „Mehrere Available/Required" eingrenzen. | + +--- + +## Header (oben) + +| Element | Funktion | +|---|---| +| **Logo links** | Kann in den Einstellungen durch dein Firmen-Logo ersetzt werden. | +| **Einstellungen** (Zahnrad-Icon) | Tenant/Client-ID, Naming-Schemata, Farben, Logo, Filter-Defaults. Wird unter `%APPDATA%\IntuneAppManager-Web\settings.json` persistiert. | +| **Hilfe** (Fragezeichen-Icon) | Diese Seite. | +| **Theme-Toggle** (Sonne/Mond-Slider) | Light/Dark-Mode umschalten. Wird im Browser-LocalStorage gemerkt. | +| **Avatar** (Personen-Icon) | Zeigt den angemeldeten Account. Mouseover für vollen UPN. Grüner Punkt = verbunden. | +| **Logout** (Tür-mit-Pfeil-Icon) | Trennt die Microsoft-Graph-Verbindung. | + +--- + +## Linke Spalte — Empfänger + +Drei Auswahl-Modi via Segment-Switch oben: + +- **Abteilung** — lädt alle Gruppen, deren Name mit einem der in den Einstellungen konfigurierten Präfixe beginnt (mehrere möglich, z.B. `abt-hm` *und* `abt-extern` für interne plus externe Mitarbeiter — siehe *Einstellungen → Abteilungs-Gruppen*). +- **RPA** — lädt die in den Einstellungen explizit aufgelisteten Gruppennamen. +- **Benutzer** — Live-Suche über Microsoft Graph. Mindestens 2 Zeichen. Felder konfigurierbar (DisplayName, UPN, Mail, Department). + +### Gruppen aufklappen + +Der Pfeil `>` rechts an einer Gruppen-Zeile öffnet die **Mitglieder inline**. Mitglieder lassen sich einzeln per Checkbox markieren und mit *Benutzer übernehmen* in den User-Modus überführen — nützlich wenn nur ein Teil einer Gruppe zugewiesen werden soll. + +### Icons pro Eintrag (immer sichtbar) + +| Icon-Form | Wirkung | +|---|---| +| Externer-Link-Pfeil | Öffnet die Gruppe/User-Seite direkt im Intune-Portal in neuem Tab. | +| Zwei-Personen-Icon | (nur bei Gruppen) Öffnet die *Mitglieder-Ansicht* der Gruppe im Intune-Portal in neuem Tab. | +| Teams-T-im-Kasten | (nur bei Benutzern) Startet einen Teams-Chat mit der Person — öffnet die Teams-Desktop-App. | + +### Mehrere Modi gleichzeitig + +Die Auswahlen in den drei Modi sind voneinander unabhängig — du kannst etwa 5 Abteilungen + 2 RPA-Gruppen + 3 Einzel-Benutzer gleichzeitig anvisieren. Die *Pinned-Targets*-Leiste oben in der linken Spalte zeigt alles, was gerade markiert ist. + +--- + +## Mittlere Spalte — Apps + +### Filter + +| Filter | Zweck | +|---|---| +| Suchfeld | Volltext-Suche im App-Namen. | +| Dropdown rechts | Preset-Filter: *Alle*, *Ohne Zuweisungen*, *Nur Available*, *Nur Required*, *Mehrere Available/Required*, *All Users/Devices*, *Nur Intune* sowie ein Eintrag *pro konfiguriertem Vendor* (z.B. *Nur PatchMyPC-Apps*, *Nur Robopack-Apps*). | +| `Bereits Gruppenmitglied` | Zeigt nur Apps, bei denen mindestens eine der ausgewählten Empfänger schon Gruppenmitglied einer zugewiesenen Gruppe ist. Funktioniert nur mit ausgewählten Empfängern. | +| `All Users/Devices ausblenden` | Native Zuweisungen aus der Anzeige rausnehmen. | +| `Geändert` (Datumsrange) | Filtert auf das *Last Modified*-Datum der App. | + +### App-Zeile + +Jede Zeile besteht von links nach rechts aus: + +1. **Status-Indikator** (Häkchen wenn alle ausgewählten Empfänger schon Mitglied einer zugewiesenen Gruppe sind). +2. **App-Name** + Hersteller. Klick öffnet das Detail-Panel. +3. **Rename-Icon** (Stift) und **Intune-Link**. Bei Vendor-verwalteten Apps (PatchMyPC, Robopack, …) ist der Stift durch ein **Lock-Icon** ersetzt. +4. **Source-Logo** — Intune, PatchMyPC, Robopack oder ein weiterer in den Einstellungen konfigurierter Vendor. Klick öffnet das jeweilige Portal in einem neuen Tab. +5. **Typ-Chip** (WIN32, MSI, APPX, WINGET, …). +6. **Version**. +7. **Available**- und **Required**-Buttons mit Count (Details siehe *Apps zuweisen — der Hauptworkflow* oben). +8. **Plus-Button** — Zuweisung hinzufügen (siehe nächster Abschnitt). +9. **Delete-Icon** (Mülltonne) — App aus Intune entfernen. Bei Vendor-verwalteten Apps (PatchMyPC, Robopack, …) ersetzt durch ein Lock. + +### Plus-Button — Zuweisung hinzufügen + +Modal mit **sechs Optionen**, gruppiert in zwei Spalten: + +**REQUIRED** (Pflichtinstallation) +- *Neue Gruppe* — erstellt eine neue Azure-AD-Sicherheitsgruppe nach dem Naming-Schema aus den Einstellungen und weist sie der App zu. +- *All Users* — native Zuweisung an alle lizenzierten Benutzer. +- *All Devices* — native Zuweisung an alle Geräte. + +**AVAILABLE** (Selbstinstallation via Firmenportal) +- *Neue Gruppe* — analog, mit Available-Naming-Schema. +- *All Users* — App erscheint im Firmenportal aller Benutzer. +- *All Devices* — App ist auf allen Geräten verfügbar. + +> **Wichtig**: Native Zuweisungen (*All Users*/*All Devices*) können je App und je Intent nur **einmal** existieren. Bereits aktive Kombinationen werden im Dialog **ausgegraut** — das verhindert 409-Fehler bei doppelten Anlagen. + +> **Unterschied zum Hauptworkflow**: Der *Plus*-Button erstellt eine **neue Zuweisung an der App selbst** (z. B. eine neue Gruppe oder ein natives Target). Der Klick auf eine *Available*/*Required*-Pille fügt dagegen Empfänger zu einer **bereits zugewiesenen Gruppe** als Mitglieder hinzu. + +--- + +## App-Detail (Klick auf App-Namen) + +Klappt unter der App-Zeile auf. Inhalt: + +| Sektion | Inhalt | +|---|---| +| **Zuweisungen** | Available + Required-Listen mit Klick-Aktionen (Intune-Link, Mitglieder, Löschen). | +| **Allgemein** | Hersteller, Entwickler, Owner, Version, Typ, Min-OS, Featured-Flag. | +| **Beschreibung** | Wird als **Markdown gerendert** — Tabellen, Listen, Code-Blöcke, fette Schrift, alles wie auf GitHub. | +| **Installation** | Datei, Setup-Pfad, Install- und Uninstall-Befehl. Copy-Icons für die Commands. | +| **Detection** | Detection-Regeln (Registry, Datei, MSI-ProductCode, PowerShell-Script). | +| **Return-Codes** | Exit-Codes mit `success` / `softReboot` / `hardReboot` / `retry`. | +| **Meta** | Erstellt + Geändert-Daten. Kombinierbar mit dem *Geändert*-Filter oben. | +| **Installationen** | Statistik aus Intune: Erfolgs-/Fehler-/Pending-/N/A-Counts getrennt für *Geräte* und *Benutzer*. Erfolgsquote als farbige Pille. | +| **Abhängigkeiten** | Visuelles Flow-Diagramm mit Klartext-Erklärung. Klick auf einen Dependency-Knoten springt zur Ziel-App (sofern in der aktuellen Liste). | +| **Supersedence** | Was diese App ersetzt/aktualisiert — analog mit Erklärung. | + +### Dependencies und Supersedence — was bedeutet was? + +**Dependency** (Abhängigkeit) +- Eine Dependency definiert, dass **diese App** eine andere App als **Voraussetzung** hat. +- *autoInstall*: Intune installiert die Dependency automatisch vor dieser App, sobald die App zugewiesen wird. +- *detect*: Die Dependency muss manuell vorhanden sein — Intune installiert sie nicht automatisch. + +**Supersedence** (Ablösung) +- Beschreibt, dass **diese App** eine andere App **ablöst**. +- *update*: Vorhandene Installation der alten App wird aktualisiert, Daten/Konfiguration bleiben erhalten. +- *replace*: Die alte App wird **deinstalliert**, dann wird die neue App installiert. Daten gehen verloren. + +--- + +## Einstellungen + +Persistiert in `%APPDATA%\IntuneAppManager-Web\settings.json`. Beim Speichern werden nur die *betroffenen* Caches geleert — bei reinen Farb-/Logo-Änderungen kein Reload nötig. + +| Sektion | Inhalt | +|---|---| +| Microsoft Graph Verbindung | Tenant- und Client-ID + Scopes. Änderungen erzwingen einen Disconnect. | +| Abteilungs-Gruppen | Ein oder mehrere Präfixe (eine pro Zeile) für den Abteilungs-Modus. | +| RPA-Gruppen | Explizite Liste der RPA-Gruppennamen (eine pro Zeile). | +| Benutzer-Suche | Welche Felder durchsucht werden (DisplayName / UPN / Mail / Department). | +| Required-Gruppen-Naming | Präfix + Suffix für die Auto-Generierung von Required-Gruppen-Namen. | +| Available-Gruppen-Naming | Analog für Available-Gruppen. | +| Branding (Logo) | Eigenes Logo hochladen (PNG/JPG/SVG/WEBP/GIF, max. 2 MB). Wird im Projekt-Verzeichnis als `branding-logo.*` gespeichert. | +| Farben | 8 Akzentfarben (Brand, Available, Required, Erfolg, Warnung, Fehler, Markierte Zeile, Detail-Bereich) + *Standardfarben wiederherstellen*-Button. | + +--- + +## Wichtige Einschränkungen + +- **Vendor-verwaltete Apps** (PatchMyPC, Robopack und weitere in den Einstellungen konfigurierte Vendoren) können weder gelöscht noch umbenannt werden über das Tool — das funktioniert nur im jeweiligen Vendor-Portal selbst. Die UI zeigt für diese Apps ein Lock-Icon statt Trash/Pencil und das Backend blockiert mit HTTP 409 (`code = "Managed"`). Detection-Regeln pro Vendor (Field/Match/Pattern) liegen in `settings.json` und sind dort editierbar — Standardwerte: PMPC = `commandLine contains "PatchMyPC"`, Robopack = `developer equals "Robopack"`. +- **Native Zuweisungen** (*All Users* / *All Devices*) sind je App und Intent **einzigartig**. Bereits aktive Kombinationen werden im Erstellen-Dialog ausgegraut. +- **Required-Zuweisungen** werden bei der nächsten Geräte-Sync von Intune **erzwungen** — Vorsicht bei produktiven Apps. Bei Änderungen an produktiven Apps lieber erst Available zuweisen. +- **App-Löschung** im Tool entfernt die App komplett aus Intune (inkl. aller Zuweisungen) — irreversibel. Gilt nicht für Vendor-verwaltete Apps (siehe oben). +- **Cache-Verhalten**: Apps und Gruppen werden nach dem ersten Laden im PowerShell-Prozess gecached. Beim Refresh-Icon wird neu geladen. Wenn du Apps oder Gruppen außerhalb des Tools änderst, manuell refreshen. + +--- + +## Vendor-Erkennung (PatchMyPC, Robopack, …) + +Das Tool unterscheidet zwischen **Vendor-verwalteten Apps** (die ueber ein externes Portal wie PatchMyPC oder Robopack ausgerollt werden) und **nativen Intune-Apps** (manuell hochgeladen). Vendor-Apps bekommen ein eigenes Source-Logo + Lock-Icons statt Trash/Pencil, weil Aenderungen in Intune beim naechsten Vendor-Sync ueberschrieben wuerden. + +### Wie wird erkannt? + +Pro Vendor gibt es eine **Detection-Regel** in den Einstellungen (`settings.json` → `vendors[]`). Eine Regel besteht aus: + +- **field** — welches Intune-App-Feld geprueft wird: + - `commandLine` (default fuer PMPC): pruef install- und uninstall-Commandline + - `developer` (default fuer Robopack): Entwickler-Feld der App + - `publisher`: Publisher-Feld + - `displayName`: App-Name + - `notes`: Notes-Feld + - `owner`: Owner-Feld +- **match** — wie verglichen wird: + - `contains`: Substring (case-insensitive) + - `equals`: exakter Match + - `startsWith`: Prefix-Match + - `regex`: voller Regex +- **pattern** — der zu suchende String / Regex + +### Standard-Regeln + +| Vendor | Feld | Match | Pattern | Erklärung | +|---|---|---|---|---| +| PatchMyPC | `commandLine` | `contains` | `PatchMyPC` | PMPC schreibt seinen Namen in den Install-/Uninstall-Aufruf (z. B. `PatchMyPC-ScriptRunner.ps1`) | +| Robopack | `developer` | `equals` | `Robopack` | Robopack-Konvention setzt das Developer-Feld der App auf "Robopack" | + +### Was, wenn meine Vendor-Apps nicht erkannt werden? + +`settings.json` unter `%APPDATA%\IntuneAppManager-Web\` oeffnen und im `vendors[]`-Array die Detection-Regel anpassen. Beispiel: wenn deine Robopack-Apps statt im Developer-Feld ueber einen Prefix im Display-Namen erkennbar sind: + +```json +{ + "id": "robopack", + "displayName": "Robopack", + "portalUrl": "https://app.robopack.com/", + "logoFile": "robopack.png", + "detection": { "field": "displayName", "match": "startsWith", "pattern": "RP - " }, + "block": { "delete": true, "rename": true } +} +``` + +Nach dem Aendern: Tool neu starten. Im Einstellungen-Modal → **Verbindung + Lookups testen** klicken — dort wird pro Vendor die aktuelle Trefferzahl angezeigt. Steht da `0`, greift die Regel nicht. + +### Reihenfolge im `vendors[]`-Array + +Der **erste passende Vendor gewinnt**. Wenn du mehrere ueberlappende Regeln hast (z.B. PMPC erkennt commandLine, Robopack erkennt developer — und eine App passt zu beiden), wird der Vendor der weiter oben in der Liste steht zugewiesen. PMPC ist im Default oben. + +### Eigenen Vendor hinzufuegen + +Neuer Eintrag im `vendors[]`-Array — beliebige `id` (klein, ohne Sonderzeichen, wird Teil des HTTP-Status-Codes `Managed`), eigener `displayName`, eigenes Logo unter `.png` im Projekt-Root. Wenn das Logo fehlt, zeigt das Tool den ersten Buchstaben des Namens als Fallback-Badge. + +--- + +## Setup-Datei aktualisieren + +Native Intune-Apps koennen mit einer neuen Setup-Datei aktualisiert werden, ohne die App neu anzulegen — Zuweisungen, Detection-Rules und Einstellungen bleiben erhalten, nur der ausgerollte Datei-Inhalt (und optional die Versionsnummer) wird ersetzt. + +- **Wo:** App-Detail aufklappen → Abschnitt **Setup-Datei** → *Setup-Datei aktualisieren…*. Der Button erscheint nur bei unterstuetzten, **nicht** vendor-verwalteten Apps (PatchMyPC/Robopack sind ausgeschlossen — die werden im jeweiligen Portal aktualisiert). +- **Unterstuetzt (aktuell):** `.intunewin` (Win32-Apps). MSI und MSIX folgen. +- **Datei-Quelle:** ein **lokaler Pfad auf dem Rechner, auf dem das Tool laeuft** (der Server liest die Datei direkt von der Platte — kein Browser-Upload). Beispiel: `C:\Pakete\app-2.0\app.intunewin`. +- **Ablauf:** Pfad eingeben → optional neue Versionsnummer → Bestaetigung anhaken → *Hochladen & aktivieren*. Das Tool legt eine neue Content-Version an, laedt die (bereits durch IntuneWinAppUtil verschluesselte) Datei nach Azure, committet sie und stellt die App auf die neue Version um. + +> **Achtung:** Das ersetzt die ausgerollte Version. Alle zugewiesenen Geraete bekommen beim naechsten Sync die neue Datei. Nicht rueckgaengig machbar — **immer zuerst an einer Test-App ausprobieren.** + +--- + +## Tastatur / Tipps + +- **Ctrl+F5** — Hard-Refresh (löscht den Browser-Cache vollständig, lädt das JS/CSS neu — Pflicht nach Tool-Updates). +- **Esc** in jedem Modal — schließen. +- **Enter** im *Umbenennen*-Modal — speichern. +- **Klick auf eine App-Zeile** außerhalb von Buttons/Links — Detail-Panel öffnen/schließen. +- **Beim Apply** wird der HTML-Report automatisch im Browser geöffnet — kann später in `%TEMP%\IntuneAppManager-Reports\` wieder aufgerufen werden. + +--- + +## Fehlerbehebung + +| Symptom | Ursache / Lösung | +|---|---| +| „User canceled authentication" beim Verbinden | Das WAM-Anmeldefenster ist hinter anderen Fenstern verborgen. Das Tool versucht es automatisch nach vorne zu holen — wenn das nicht hilft, schaue in der Taskleiste oder klicke nochmal *Verbinden*. | +| Apps laden ewig | Bei großen Tenants (> 1000 Apps) kann das initiale Laden 10–30 s dauern. Apps werden danach gecached. Neu laden über Refresh-Icon. | +| Änderungen am Settings/Logo wirken nicht | Browser-Cache. **Ctrl+F5** drücken. | +| „Gruppe existiert bereits" beim Erstellen | Wahrscheinlich gibt es schon eine Gruppe mit dem generierten Namen. Im Dialog den Vorschlag manuell anpassen. | +| PowerShell-Konsole zeigt Errors beim Start | Module fehlen oder sind zu alt. `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force` ausführen. | +| Apply-Button bleibt grau / disabled | Es sind 0 Vorgänge in der Session, oder alle gesammelten Operationen sind native Targets (werden übersprungen). Mindestens einen Empfänger + Pille setzen. | + +--- + +Stand: Mai 2026 · Tool-Version siehe Konsolen-Output beim Start und im Apply-Report-Footer. Hilfe-Datei: `www/help.md`. Vollständige Änderungshistorie: `CHANGELOG.md`. diff --git a/Intune/Intune-App-Manager-Web/www/index.html b/Intune/Intune-App-Manager-Web/www/index.html new file mode 100644 index 0000000..5286a81 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/index.html @@ -0,0 +1,807 @@ + + + + + + Intune App-Manager + + + + + + + + + + +
    +
    + +
    +
    Intune App-Manager
    +
    +
    + +
    + + + +
    + + Offline +
    + +
    +
    + + +
    + + +
    + + +
    +
    +
    +

    Abteilungen & Benutzer

    + 0 +
    + +
    + +
    + + + +
    + +
    + + + +
    + +
    +
    + + · + +
    +
    + + + +
    + +
    +
    + + +
    +
    +
    +

    Apps

    + 0 +
    +
    + +
    +
    + +
    +
    + + +
    + + +
    + +
    + + +
    + Geändert + + – + + +
    +
    + ✓ bereits zugewiesen + ◐ teilweise +
    +
    + +
    + +
    +
    + + + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Intune/Intune-App-Manager-Web/www/members.html b/Intune/Intune-App-Manager-Web/www/members.html new file mode 100644 index 0000000..8bd2e7b --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/members.html @@ -0,0 +1,151 @@ + + + + + + Gruppen-Mitglieder · Intune App-Manager + + + + + + + + +
    +
    + +
    +
    Gruppen-Mitglieder
    +
    +
    + +
    + +
    +
    +
    +

    Mitglieder werden geladen...

    +
    + + 0 +
    +
    + +
    +
    + + +
    + +
    + +
    +
    +
    + +
    + + + + + diff --git a/Intune/Intune-App-Manager-Web/www/styles.css b/Intune/Intune-App-Manager-Web/www/styles.css new file mode 100644 index 0000000..14c4c2c --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/styles.css @@ -0,0 +1,4402 @@ +/* ============================================================= + Intune App-Manager — Cal.com-Aesthetik + Weißes Canvas, schwarze CTAs, Inter, dezente Hairlines. + ============================================================= */ + +:root { + /* Cal.com Tokens */ + --primary: #111111; + --primary-active: #242424; + --primary-disabled: #e5e7eb; + --ink: #111111; + --body: #374151; + --muted: #6b7280; + --muted-soft: #898989; + --hairline: #e5e7eb; + --hairline-soft: #f3f4f6; + --canvas: #ffffff; + --surface-soft: #f8f9fa; + --surface-card: #f5f5f5; + --surface-strong: #e5e7eb; + --surface-dark: #101010; + --surface-dark-elev: #1a1a1a; + --on-primary: #ffffff; + --on-dark: #ffffff; + --on-dark-soft: #a1a1aa; + + --brand-primary: #27a078; /* Logo-Hintergrund, Stepper-Indikator — Firmenfarbe */ + --brand-accent: #3b82f6; /* Available-Pillen, Links, Akzent-Hover */ + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + --pink: #ec4899; + --violet: #8b5cf6; + --emerald: #34d399; + --orange: #fb923c; + --teal: #14b8a6; + --teal-deep: #0d9488; + + /* Tinted accent backgrounds — automatisch angepasst per Dark-Mode-Override */ + --success-bg: rgba(16,185,129,0.05); + --success-soft: rgba(16,185,129,0.12); + --success-strong: rgba(16,185,129,0.18); + --success-border: rgba(16,185,129,0.28); + --success-shadow: rgba(16,185,129,0.35); + --error-bg: rgba(239,68,68,0.06); + --error-soft: rgba(239,68,68,0.12); + --error-strong: rgba(239,68,68,0.18); + --error-border: rgba(239,68,68,0.30); + --warning-bg: rgba(245,158,11,0.06); + --warning-soft: rgba(245,158,11,0.12); + --warning-strong: rgba(245,158,11,0.18); + --warning-border: rgba(245,158,11,0.30); + --accent-bg: rgba(59,130,246,0.06); + --accent-soft: rgba(59,130,246,0.12); + --accent-strong: rgba(59,130,246,0.18); + --pink-soft: rgba(236,72,153,0.12); + --violet-soft: rgba(139,92,246,0.12); + --teal-soft: rgba(20,184,166,0.12); + + --app-expanded-bg: #f7f7f7; + + /* Aufgeklappte App-Zeile: solid background, kein rgba. Wird vom Frontend + per applyThemeColors() mit der gewaehlten Farbe 1:1 ueberschrieben. */ + --row-selected-border: #71e5c4; + --row-selected-bg: #71e5c4; + --row-selected-bg-hover: #71e5c4; + + /* Radii */ + --r-xs: 4px; + --r-sm: 6px; + --r-md: 8px; + --r-lg: 12px; + --r-xl: 16px; + --r-pill: 9999px; + + /* Spacing (Cal token-Basis 4px) */ + --s-xxs: 4px; + --s-xs: 8px; + --s-sm: 12px; + --s-md: 16px; + --s-lg: 24px; + --s-xl: 32px; + + /* Shadows — Cal: subtle, soft */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); + + --tx: 150ms cubic-bezier(0.2, 0.7, 0.3, 1); + --tx-slow: 240ms cubic-bezier(0.2, 0.7, 0.3, 1); + + --font-display: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace; +} + +@supports (font-variation-settings: normal) { + :root { --font-display: 'Inter var', 'Inter', sans-serif; --font-body: 'Inter var', 'Inter', sans-serif; } +} + +/* ============================================================= + Dark Mode — blau-getoente Schwarz-Skala, equal lightness steps, + entsaettigte Akzentfarben. Wird via data-theme="dark" am + aktiviert, JS persistiert die Wahl in localStorage. + ============================================================= */ +html[data-theme="dark"] { + --primary: #ffffff; + --primary-active: #f0f1f5; + --primary-disabled: #3a4150; + --ink: #f5f6fa; + --body: #d8dde6; + --muted: #9aa3b3; + --muted-soft: #6b7383; + --hairline: #353b4a; + --hairline-soft: #2a2f3d; + --canvas: #1f2430; /* aufgehellt — kein tiefschwarz mehr */ + --surface-soft: #262b38; + --surface-card: #2c313f; + --surface-strong: #353b4a; + --surface-dark: #f5f6fa; + --surface-dark-elev: #e0e4eb; + --on-primary: #1f2430; + --on-dark: #1f2430; + --on-dark-soft: #6b7383; + + /* Tints im Dark-Mode kraeftiger, mit den hellen Akzentfarben */ + --success-bg: rgba(102,204,190,0.10); + --success-soft: rgba(102,204,190,0.18); + --success-strong: rgba(102,204,190,0.26); + --success-border: rgba(102,204,190,0.40); + --success-shadow: rgba(102,204,190,0.45); + --error-bg: rgba(231,139,139,0.10); + --error-soft: rgba(231,139,139,0.18); + --error-strong: rgba(231,139,139,0.26); + --error-border: rgba(231,139,139,0.40); + --warning-bg: rgba(229,196,67,0.10); + --warning-soft: rgba(229,196,67,0.18); + --warning-strong: rgba(229,196,67,0.26); + --warning-border: rgba(229,196,67,0.40); + --accent-bg: rgba(127,177,240,0.10); + --accent-soft: rgba(127,177,240,0.18); + --accent-strong: rgba(127,177,240,0.26); + --pink-soft: rgba(240,166,204,0.18); + --violet-soft: rgba(192,134,234,0.18); + --teal-soft: rgba(102,204,190,0.18); + + /* Akzent-Farben entsaettigen — hellere Variante fuer dunklen Hintergrund */ + --brand-primary: #5ec4a0; + --brand-accent: #7fb1f0; + --success: #66ccbe; + --warning: #e5c443; + --error: #e78b8b; + --pink: #f0a6cc; + --violet: #c086ea; + --emerald: #66ccbe; + --orange: #f4a261; + --teal: #66ccbe; + --teal-deep: #5cc0b1; + + /* Schatten dezenter im Dark Mode */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.6); +} + +/* Verhindert Flash beim ersten Render */ +html[data-theme="dark"] body { background: var(--canvas); color: var(--body); } + +/* Waehrend des Theme-Wechsels ALLE Transitions deaktivieren — sonst animiert + jedes Element gleichzeitig seine Farbaenderungen und das ganze Layout ruckelt. */ +html.theme-switching, +html.theme-switching *, +html.theme-switching *::before, +html.theme-switching *::after { + transition: none !important; + animation-duration: 0s !important; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: var(--canvas); + color: var(--ink); + font-family: var(--font-body); + font-size: 14px; + line-height: 1.5; + font-feature-settings: 'cv11', 'ss01', 'ss03'; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; + color: var(--body); +} + +button { font-family: inherit; cursor: pointer; } +input, select { font-family: inherit; } +a { color: var(--ink); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 2px; } +.hidden { display: none !important; } +.flex-1 { flex: 1; } +.muted { color: var(--muted); } + +/* Display headlines: -0.04em letter-spacing on Inter approximates Cal Sans */ +h1, h2, h3 { + font-family: var(--font-display); + color: var(--ink); + font-weight: 600; + letter-spacing: -0.025em; +} + +/* ============================================================= + HEADER (top-nav) + ============================================================= */ + +.topbar { + display: grid; + /* Stepper wurde entfernt — Header hat jetzt nur noch Brand links und + Header-Actions rechts. 1fr auf der rechten Spalte pinnt die Actions ans + rechte Ende durch justify-self im Header-Actions-Block. */ + grid-template-columns: auto 1fr; + align-items: center; + gap: 32px; + height: 64px; + padding: 0 32px; + background: var(--canvas); + border-bottom: 1px solid var(--hairline); + position: sticky; + top: 0; + z-index: 50; +} +.topbar .header-actions { justify-self: end; } + +.brand { display: flex; align-items: center; gap: 12px; } + +.logo { + width: 32px; height: 32px; + border-radius: var(--r-md); + background: var(--brand-primary); + color: var(--on-primary); + display: grid; place-items: center; + font-weight: 700; + font-size: 16px; + letter-spacing: -0.04em; + overflow: hidden; + flex-shrink: 0; +} +/* Wenn ein eigenes Logo gesetzt ist: Hintergrund weg und Bild voll fuellen. */ +.logo.has-custom-logo { + background: transparent; + border-radius: var(--r-sm); +} +.logo-img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.brand-name { + font-size: 15px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.02em; +} + +.header-actions { display: flex; align-items: center; gap: 12px; } + +/* Theme-Toggle — Slide-Switch */ +.theme-toggle { + appearance: none; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + flex-shrink: 0; + display: inline-flex; + align-items: center; +} +.theme-track { + position: relative; + display: block; + width: 44px; + height: 22px; + border-radius: 999px; + background: linear-gradient(135deg, #fcd34d 0%, #f59e0b 100%); + transition: background-color var(--tx-slow); + box-shadow: + inset 0 1px 2px rgba(120, 70, 0, 0.30), + inset 0 0 0 1px rgba(120, 70, 0, 0.18); +} +.theme-toggle:hover .theme-track { + filter: brightness(1.06); +} +.theme-ico { + position: absolute; + top: 50%; + width: 11px; + height: 11px; + z-index: 2; /* Icons UEBER dem Knob — Knob wirkt wie ein "Halter" */ + transform: translateY(-50%); + transition: opacity var(--tx); + pointer-events: none; +} +/* Knob-Mitte links: 3 + 8 = 11; Sun 11px breit -> left = 11 - 5.5 = 5.5 */ +.theme-ico-sun { left: 6px; color: #b45309; } +/* Knob-Mitte rechts: 44 - 3 - 8 = 33; Moon 11px breit -> right = 44 - 33 - 5.5 = 5.5 */ +.theme-ico-moon { right: 6px; color: #1e293b; transform: translate(1px, -50%); } +.theme-knob { + position: absolute; + top: 50%; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.30), 0 0 0 1px rgba(0, 0, 0, 0.06); + transform: translate3d(0, -50%, 0); + transition: transform 220ms cubic-bezier(0.34, 1.2, 0.64, 1), background-color 220ms ease-out; + z-index: 1; /* unter den Icons */ + will-change: transform; +} + +/* Light-Mode: Sun sichtbar (sitzt unter dem Knob → erscheint AUF dem Knob), Moon ausgeblendet */ +html:not([data-theme="dark"]) .theme-ico-moon { opacity: 0; } +html:not([data-theme="dark"]) .theme-ico-sun { opacity: 1; } + +/* Dark-Mode: Knob slidet nach rechts (60 - 24 - 3 - 3 = 30) */ +html[data-theme="dark"] .theme-track { + background: #3b384f; + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.35), + inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} +html[data-theme="dark"] .theme-knob { + /* Slide-Distance: 44 - 16 - 3 - 3 = 22 */ + transform: translate3d(22px, -50%, 0); +} +html[data-theme="dark"] .theme-ico-sun { opacity: 0; } +html[data-theme="dark"] .theme-ico-moon { opacity: 1; } + +/* Connection pill */ +/* Connection-Pill ist jetzt ein Avatar-Icon (Bootstrap person-circle). + Kein Border/Background — das Icon bringt seinen eigenen Kreis-Outline mit. + Status durch Icon-Farbe + kleiner Online-Dot unten rechts. Tooltip zeigt + den vollen Account-String. */ +.conn-pill { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + color: var(--muted); + cursor: default; + transition: color var(--tx); + user-select: none; +} +.conn-pill .conn-text { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} +.conn-pill .conn-text svg { + display: block; +} +/* Online-Dot rechts unten — Status-Indikator wie aus Teams/Slack bekannt */ +.conn-pill .dot { + position: absolute; + right: 4px; + bottom: 4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted-soft); + border: 1.5px solid var(--canvas); + box-sizing: content-box; +} +.conn-off { color: var(--muted); } +.conn-on { color: var(--brand-primary); } +.conn-on .dot { background: var(--success); animation: pulse 2.4s infinite; } +.conn-busy { color: var(--warning); } +.conn-busy .dot { background: var(--warning); animation: pulse 0.9s infinite; } + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} + +/* ============================================================= + BUTTONS — Cal.com (black primary, white secondary, text link) + ============================================================= */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 20px; + border-radius: var(--r-md); + font-size: 14px; + font-weight: 600; + border: 1px solid transparent; + background: transparent; + color: var(--ink); + transition: all var(--tx); + white-space: nowrap; + letter-spacing: -0.005em; + user-select: none; + font-family: inherit; +} + +.btn:active:not(:disabled) { transform: scale(0.98); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-sm { height: 32px; padding: 0 12px; font-size: 13px; } +.btn-lg { height: 44px; padding: 0 24px; font-size: 15px; } + +.btn-primary { + background: var(--primary); + color: var(--on-primary); +} +.btn-primary:hover:not(:disabled) { background: var(--primary-active); } +.btn-primary:disabled { background: var(--primary-disabled); color: var(--muted); } + +.btn-secondary { + background: var(--canvas); + color: var(--ink); + border-color: var(--hairline); +} +.btn-secondary:hover:not(:disabled) { + background: var(--surface-soft); + border-color: var(--surface-strong); +} + +.btn-text { + background: transparent; + color: var(--muted); + font-weight: 500; + height: 36px; + padding: 0 12px; +} +.btn-text:hover:not(:disabled) { color: var(--ink); background: var(--surface-soft); } + +.btn-apply { + width: 100%; + height: 44px; + background: var(--primary); + color: var(--on-primary); + font-weight: 600; + border-radius: var(--r-md); + margin-top: 4px; +} +.btn-apply:hover:not(:disabled) { background: var(--primary-active); } +.btn-apply:disabled { background: var(--primary-disabled); color: var(--muted); } + +/* Icon button — circular */ +.icon-btn { + width: 32px; height: 32px; + border-radius: var(--r-md); + background: transparent; + border: 1px solid transparent; + color: var(--muted); + display: inline-grid; + place-items: center; + transition: all var(--tx); +} +.icon-btn:hover { background: var(--surface-soft); color: var(--ink); border-color: var(--hairline); } + +/* Link-style button */ +.link-btn { + background: transparent; + border: none; + color: var(--ink); + font-size: 13px; + font-weight: 500; + padding: 4px 6px; + border-radius: var(--r-sm); + letter-spacing: -0.005em; +} +.link-btn:hover { background: var(--surface-soft); } +.link-btn-danger { color: var(--error); display: block; margin: 8px auto 0; } +.link-btn-danger:hover { background: var(--error-bg); } + +.dotsep { color: var(--muted-soft); font-size: 12px; } + +/* ============================================================= + LAYOUT + ============================================================= */ + +.layout { + display: grid; + grid-template-columns: 440px minmax(0, 1fr) 440px; + gap: 14px; + padding: 16px 20px 24px; + max-width: 1920px; + margin: 0 auto; + width: 100%; + flex: 1; + align-items: stretch; +} + +.panel { + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-lg); + display: flex; + flex-direction: column; + overflow: hidden; + position: sticky; + top: 80px; + height: calc(100vh - 96px); /* feste, identische Hoehe */ +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 16px 12px; + gap: 12px; +} + +.panel-title { display: flex; align-items: center; gap: 10px; } +.panel-title h2 { + font-size: 16px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.015em; +} + +.panel-head-actions { display: flex; gap: 4px; } + +.count-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--r-pill); + font-size: 12px; + font-weight: 500; + background: var(--surface-card); + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.count-badge.accent { + background: var(--ink); + color: var(--on-primary); +} + +/* ============================================================= + MODE SEGMENT — nav-pill-group + ============================================================= */ + +.mode-segment { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + margin: 0 16px 12px; + padding: 3px; + background: var(--surface-soft); + border-radius: var(--r-pill); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.06), + inset 0 0 0 1px var(--hairline); +} + +.seg { + position: relative; + padding: 7px 10px; + background: transparent; + border: none; + border-radius: var(--r-pill); + color: var(--muted); + font-size: 13px; + font-weight: 500; + transition: color 180ms ease-out; + letter-spacing: -0.005em; + z-index: 1; +} +.seg:hover { color: var(--ink); } +.seg.active { + background: var(--ink); + color: var(--on-primary); + font-weight: 600; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.30), + 0 0 0 1px rgba(0, 0, 0, 0.06); +} +.seg.active:hover { color: var(--on-primary); } + +/* ============================================================= + FORMS + ============================================================= */ + +.search-wrap { + position: relative; + margin: 0 16px 12px; + display: flex; + align-items: center; +} + +.search-ico { + position: absolute; + left: 12px; + color: var(--muted); + pointer-events: none; +} + +.input, .select { + width: 100%; + height: 38px; + padding: 0 14px; + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-md); + color: var(--ink); + font-size: 14px; + transition: all var(--tx); + font-family: inherit; + letter-spacing: -0.005em; +} + +.input-search { padding-left: 36px; padding-right: 80px; } + +.input:focus, .select:focus { + outline: none; + border-color: var(--ink); + box-shadow: 0 0 0 3px rgba(17,17,17,0.08); +} + +.input::placeholder { color: var(--muted-soft); } + +.select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236b7280'%3E%3Cpath d='M4 6l4 4 4-4z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 32px; +} + +.search-hint { + position: absolute; + right: 10px; + font-size: 10px; + color: var(--muted); + background: var(--surface-card); + padding: 3px 7px; + border-radius: var(--r-sm); + pointer-events: none; + font-weight: 500; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.select-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 16px 10px; +} + +.select-info { + font-size: 12px; + color: var(--muted); + font-weight: 500; +} + +.select-actions { display: flex; align-items: center; gap: 4px; } + +.apps-toolbar { + display: flex; + gap: 8px; + margin: 0 16px 10px; +} +.apps-toolbar .search-wrap { margin: 0; flex: 1; } +.apps-toolbar .select { width: 180px; flex-shrink: 0; } + +.filter-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 20px; + margin: 0 16px 12px; +} + +.legend-group { + display: inline-flex; + align-items: center; + gap: 12px; + margin-left: auto; + padding-left: 12px; +} + +/* Date-Range-Filter (Geaendert von-bis) — kein Pillen-Rahmen mehr, + liegt nahtlos in der Filter-Zeile zwischen den anderen Filtern. */ +.date-filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + background: transparent; + border: none; + border-radius: 0; + font-size: 12px; + color: var(--body); + height: 28px; + box-sizing: border-box; +} +.date-filter-label { + font-weight: 500; + color: var(--body); + cursor: help; + line-height: 1; +} +/* Date-Inputs: gleiche Schriftart wie der Rest der UI (Inter), keine + Monospace. Hoehe so dass sie sauber im Chip sitzen. flatpickr legt ein + altInput direkt nach dem Original an — beide selectoren werden gestyled. */ +.date-filter-chip .date-input, +.date-filter-chip input.flatpickr-input { + border: 1px solid var(--hairline); + background: var(--canvas); + border-radius: var(--r-xs); + padding: 0 8px; + font-family: inherit; + font-size: 12px; + color: var(--ink); + width: 96px; + height: 22px; + box-sizing: border-box; + transition: none; + outline: none; + box-shadow: none; +} +.date-filter-chip .date-input:focus, +.date-filter-chip input.flatpickr-input:focus { + outline: none; + border-color: var(--brand-accent); +} +.date-sep { color: var(--muted); font-weight: 400; } +.date-clear { + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 4px; + border-radius: var(--r-xs); + transition: all var(--tx); +} +.date-clear:hover { color: var(--error); background: var(--error-bg); } + +/* Dark-Mode: Input-Background dunkel halten — flatpickr macht den Picker- + Popup selbst (siehe Theming weiter unten). */ +html[data-theme="dark"] .date-filter-chip .date-input, +html[data-theme="dark"] .date-filter-chip input.flatpickr-input { + color: var(--ink); + background: var(--surface-soft); + border-color: var(--hairline); +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--muted); + cursor: help; +} + +.legend-item .row-status { + width: 14px; height: 14px; + font-size: 9px; +} + +.check-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 4px 0 0; + background: transparent; + border: none; + border-radius: 0; + font-size: 12px; + color: var(--body); + cursor: pointer; + transition: color var(--tx); + user-select: none; + font-weight: 500; + /* Exakte Hoehe — synchron mit .date-filter-chip damit alles in der + Filter-Zeile auf einer Linie ist. */ + height: 28px; + box-sizing: border-box; +} + +.check-chip:hover { color: var(--ink); } +.check-chip input[type=checkbox] { + appearance: none; + width: 14px; height: 14px; + border: 1.5px solid var(--surface-strong); + border-radius: var(--r-xs); + display: inline-grid; place-items: center; + cursor: pointer; + transition: all var(--tx); +} +.check-chip input[type=checkbox]:checked { + background: var(--ink); + border-color: var(--ink); +} +.check-chip input[type=checkbox]:checked::after { + content: '✓'; + font-size: 10px; + color: var(--on-primary); + font-weight: 900; + line-height: 1; +} +/* Active-State: nur Text-Farbe verstaerken, kein flaechiger BG-Fill. + Der gefuellte Checkbox-Indikator (siehe input[type=checkbox]:checked + weiter oben) gibt schon ausreichend visuelles Feedback. */ +.check-chip:has(input:checked) { + color: var(--ink); + font-weight: 600; +} + +/* ============================================================= + LIST AREAS + ============================================================= */ + +.list-area { + flex: 1; + overflow-y: auto; + border-top: 1px solid var(--hairline); + background: var(--canvas); +} + +.list-area::-webkit-scrollbar { width: 8px; } +.list-area::-webkit-scrollbar-track { background: transparent; } +.list-area::-webkit-scrollbar-thumb { background: var(--surface-strong); border-radius: 4px; } +.list-area::-webkit-scrollbar-thumb:hover { background: var(--muted-soft); } + +/* Target rows */ +.target-row { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--hairline-soft); + cursor: pointer; + transition: background var(--tx); + min-height: 38px; +} + +.target-row:hover { background: var(--surface-soft); } +.target-row.selected { + background: rgba(16,185,129,0.22); + border-left: 2px solid var(--success); + padding-left: 14px; +} +.target-row.selected:hover { + background: rgba(16,185,129,0.30); +} +html[data-theme="dark"] .target-row.selected { background: var(--success-strong); } +html[data-theme="dark"] .target-row.selected:hover { background: var(--success-strong); filter: brightness(1.1); } + +/* Gruppen ohne Mitglieder — analog zu Apps ohne Zuweisungen */ +.target-row.empty .target-name, +.target-row.empty a.target-link { + color: var(--error); +} +.target-row.empty a.target-link:hover { + filter: brightness(0.9); +} +.target-row.empty .target-meta { + color: var(--error); + opacity: 0.7; +} + +/* Flash-Animation beim Springen aus Pin-Chip */ +@keyframes flash-row { + 0% { background: var(--success-shadow); } + 100% { background: var(--success-soft); } +} +.target-row.flash { animation: flash-row 1.2s ease-out; } + +/* Pinned-Targets — kompakte Chip-Leiste, immer sichtbar oberhalb der Liste */ +.pinned-targets { + background: var(--success-bg); + border-top: 1px solid var(--success-strong); + border-bottom: 1px solid var(--success-strong); + padding: 8px 16px 10px 16px; + max-height: 160px; + overflow-y: auto; + flex-shrink: 0; +} +.pin-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.pin-head-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--success); +} +.pin-head-count { + font-size: 11px; + background: var(--success); + color: white; + padding: 1px 7px; + border-radius: 10px; + font-weight: 600; +} +.pin-clear-all { + margin-left: auto; + font-size: 11.5px; + color: var(--muted); +} +.pin-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.pin-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--canvas); + border: 1px solid var(--success-shadow); + border-radius: 12px; + padding: 2px 4px 2px 8px; + font-size: 12px; + cursor: pointer; + transition: all var(--tx); + max-width: 100%; +} +.pin-chip:hover { + background: var(--success-strong); + border-color: var(--success); +} +.pin-chip.pin-empty { + border-color: var(--error-border); +} +.pin-chip.pin-empty .pin-name { color: var(--error); } +.pin-mode { + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--success); + background: var(--success-soft); + padding: 1px 5px; + border-radius: 6px; + flex-shrink: 0; +} +.pin-name { + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} +.pin-remove { + appearance: none; + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + width: 18px; height: 18px; + border-radius: 50%; + display: inline-grid; + place-items: center; + font-size: 14px; + line-height: 1; + flex-shrink: 0; + transition: all var(--tx); +} +.pin-remove:hover { + background: var(--error-soft); + color: var(--error); +} + +.target-row input[type=checkbox] { + appearance: none; + width: 16px; height: 16px; + border: 1.5px solid var(--surface-strong); + border-radius: var(--r-xs); + display: inline-grid; place-items: center; + cursor: pointer; + transition: all var(--tx); + flex-shrink: 0; + background: var(--canvas); +} +.target-row input[type=checkbox]:checked { + background: var(--ink); + border-color: var(--ink); +} +.target-row input[type=checkbox]:checked::after { + content: '✓'; + font-size: 11px; + color: var(--on-primary); + font-weight: 900; + line-height: 1; +} + +.target-name { + flex: 1; + font-size: 13.5px; + color: var(--ink); + word-break: break-word; + font-weight: 500; + letter-spacing: -0.005em; +} + +/* Empfaenger-Name als Link → in Entra oeffnen */ +a.target-link { + color: inherit; + text-decoration: none; +} +a.target-link:hover { + color: var(--brand-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.target-meta { + font-size: 12px; + color: var(--muted); + margin-top: 2px; + font-weight: 400; +} + +/* Entra-Link-Icon (User/Group im Entra-Portal oeffnen) */ +.entra-link { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: var(--r-sm); + background: transparent; + border: 1px solid transparent; + color: var(--muted); + flex-shrink: 0; + text-decoration: none; + opacity: 0; + transition: all var(--tx); +} +.target-row:hover .entra-link, +.ex-member-row:hover .entra-link { opacity: 1; } +.entra-link:hover { + background: var(--surface-soft); + color: var(--brand-accent); + border-color: var(--hairline); + text-decoration: none; +} +/* Teams: Hover-Farbe im offiziellen Teams-Lila — sofortiger Wiedererkennungswert */ +.entra-link.teams-link:hover { + color: #4b53bc; + background: rgba(75, 83, 188, 0.10); +} +html[data-theme="dark"] .entra-link.teams-link:hover { + color: #8a91e3; + background: rgba(138, 145, 227, 0.16); +} + +/* Expand-Button auf dept/rpa-Zeilen → Mitglieder inline einsehen */ +.row-expand { + width: 24px; + height: 24px; + border-radius: var(--r-sm); + background: transparent; + border: 1px solid var(--hairline); + color: var(--muted); + font-size: 16px; + font-weight: 700; + display: inline-grid; + place-items: center; + flex-shrink: 0; + cursor: pointer; + transition: all var(--tx); + line-height: 1; +} +.row-expand:hover { + background: var(--surface-soft); + color: var(--ink); + border-color: var(--ink); +} +.row-expand.is-open { + background: var(--ink); + color: var(--on-primary); + border-color: var(--ink); + transform: rotate(90deg); +} + +/* Inline-Aufklapp-Bereich */ +.target-row-wrap { + border-bottom: 1px solid var(--hairline-soft); +} +.target-row-wrap > .target-row { border-bottom: none; } +.target-row-wrap.expanded { + background: var(--surface-soft); +} + +.expand-body { + padding: 8px 12px 12px; + border-top: 1px solid var(--hairline); + background: var(--surface-soft); + display: flex; + flex-direction: column; + gap: 8px; +} + +.expand-search .input { height: 32px; font-size: 12.5px; } + +.expand-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11.5px; + color: var(--muted); + padding: 0 2px; +} +.expand-count strong { color: var(--ink); font-weight: 700; } +.expand-actions { display: flex; align-items: center; gap: 4px; } + +.expand-list { + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-md); + max-height: 280px; + overflow-y: auto; +} + +.ex-member-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 12px; + border-bottom: 1px solid var(--hairline-soft); + cursor: pointer; + transition: background var(--tx); + min-height: 36px; +} +.ex-member-row:last-child { border-bottom: none; } +.ex-member-row:hover { background: var(--surface-soft); } +.ex-member-row.selected { + background: var(--surface-soft); + border-left: 2px solid var(--ink); + padding-left: 10px; +} + +.ex-member-row input[type=checkbox] { + appearance: none; + width: 14px; height: 14px; + border: 1.5px solid var(--surface-strong); + border-radius: var(--r-xs); + display: inline-grid; place-items: center; + cursor: pointer; + transition: all var(--tx); + flex-shrink: 0; + background: var(--canvas); +} +.ex-member-row input[type=checkbox]:checked { + background: var(--ink); + border-color: var(--ink); +} +.ex-member-row input[type=checkbox]:checked::after { + content: '✓'; + font-size: 10px; + color: var(--on-primary); + font-weight: 900; + line-height: 1; +} + +.ex-member-info { flex: 1; min-width: 0; } +.ex-member-name { + font-size: 12.5px; + font-weight: 500; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ex-member-upn { + font-size: 11px; + color: var(--muted); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +/* Member-Icons (Entra-Link + Teams-Chat) — rechtsbuendig und immer voll + sichtbar, identisch zur Empfaenger-Liste oben. */ +.ex-member-icons { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + margin-left: auto; +} +.ex-member-icons .entra-link { + opacity: 1; +} + +.expand-loading, +.expand-error, +.expand-empty { + padding: 16px; + text-align: center; + font-size: 12.5px; + color: var(--muted); + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-md); +} +.expand-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.expand-error { color: var(--error); } + +.expand-foot { + display: flex; + justify-content: flex-end; +} + +/* Member-Liste im Modal */ +.mm-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 0 0 8px; + border-bottom: 1px solid var(--hairline-soft); + margin-bottom: 8px; +} + +.member-list { + max-height: 50vh; + overflow-y: auto; + border: 1px solid var(--hairline); + border-radius: var(--r-md); +} + +.member-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--hairline-soft); + cursor: pointer; + transition: background var(--tx); + min-height: 40px; +} +.member-row:last-child { border-bottom: none; } +.member-row:hover { background: var(--surface-soft); } +.member-row.selected { + background: var(--surface-soft); + border-left: 2px solid var(--ink); + padding-left: 10px; +} + +.member-row input[type=checkbox] { + appearance: none; + width: 16px; height: 16px; + border: 1.5px solid var(--surface-strong); + border-radius: var(--r-xs); + display: inline-grid; place-items: center; + cursor: pointer; + transition: all var(--tx); + flex-shrink: 0; + background: var(--canvas); +} +.member-row input[type=checkbox]:checked { + background: var(--ink); + border-color: var(--ink); +} +.member-row input[type=checkbox]:checked::after { + content: '✓'; + font-size: 11px; + color: var(--on-primary); + font-weight: 900; + line-height: 1; +} + +.member-info { flex: 1; min-width: 0; } +.member-name { + font-size: 13.5px; + font-weight: 500; + color: var(--ink); + letter-spacing: -0.005em; +} +.member-upn { + font-size: 11.5px; + color: var(--muted); + font-family: var(--font-mono); + margin-top: 1px; +} + +/* ============================================================= + APPS LIST — kompakt, 2 Spalten + ============================================================= */ + +.apps-list { border-radius: 0; } + +/* Tabellen-Layout: feste Spalten damit alles sauber untereinander steht. + Name nimmt den GANZEN Rest (1fr), andere Spalten so klein wie noetig. */ +.app-row { + display: grid; + grid-template-columns: + 22px /* status */ + minmax(200px, 1fr) /* name + publisher — alles uebrige */ + 24px /* source-logo (Intune/PMPC) */ + 58px /* type chip */ + 92px /* version */ + 98px /* available */ + 98px /* required */ + 26px /* + */ + 26px; /* delete */ + column-gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--hairline-soft); + transition: background var(--tx); + align-items: center; + /* Reihe waechst bei Bedarf — Default 48px, aber kein hartes Limit damit + lange App-Namen umbrechen koennen ohne abgeschnitten zu werden. */ + min-height: 48px; +} + +.app-row { cursor: pointer; } +.app-row:hover { background: var(--surface-soft); } +.app-row.has-queued { + background: var(--surface-soft); + box-shadow: inset 2px 0 0 var(--ink); +} +.app-row.is-open { + background: var(--row-selected-bg); + box-shadow: inset 3px 0 0 var(--row-selected-border); +} +.app-row.is-open:hover { + background: var(--row-selected-bg-hover); +} +/* Text und Icons werden vom Frontend auf Kontrast berechnet — vars werden + in applyThemeColors() gesetzt. Fallbacks fuer den allerersten Render. */ +.app-row.is-open .app-name { + color: var(--row-selected-text, var(--ink)); + font-weight: 600; +} +.app-row.is-open .app-publisher { + color: var(--row-selected-text-muted, var(--muted)); +} +.app-row.is-open .app-type, +.app-row.is-open .app-version { + color: var(--row-selected-text-muted, var(--body)); +} +/* Icons in markierter Row: volle Sichtbarkeit (overridet die globale + opacity: 0/0.55-Regel) UND Kontrast-Farbe aus applyThemeColors. */ +.app-row.is-open .app-name-icons .entra-link, +.app-row.is-open .app-rename-btn, +.app-row.is-open .app-rename-locked, +.app-row.is-open .col-delete .app-delete-btn, +.app-row.is-open .col-delete .app-delete-locked { + color: var(--row-selected-icon, currentColor) !important; + opacity: 0.85; +} +.app-row.is-open .app-name-icons .entra-link:hover, +.app-row.is-open .app-rename-btn:hover, +.app-row.is-open .col-delete .app-delete-btn:hover { + opacity: 1; + color: var(--row-selected-icon-hover, currentColor) !important; +} +/* Source-Logo + Type/Version Chip in markierter Row — Overlay-Vars werden + in applyThemeColors aus der effektiven BG-Helligkeit abgeleitet, daher + passt es automatisch sowohl in Light- als auch Dark-Mode. */ +.app-row.is-open .app-source { opacity: 0.95; } +.app-row.is-open .app-type { + background: var(--row-selected-surface-strong, var(--surface-soft)); + color: var(--row-selected-text, var(--ink)) !important; +} +.app-row.is-open .app-version { + background: var(--row-selected-surface-strong, var(--surface-soft)); + color: var(--row-selected-text, var(--body)) !important; +} +/* Queue-Badge invertieren */ +.app-row.is-open .row-queued { + background: var(--row-selected-text, var(--ink)); + color: var(--row-selected-bg, var(--on-primary)); +} + +.app-row-wrap { + border-bottom: 1px solid var(--hairline-soft); +} +.app-row-wrap > .app-row { border-bottom: none; } +.app-row-wrap.expanded { + background: var(--app-expanded-bg); + box-shadow: + inset 0 0 0 1px var(--success-border), + 0 6px 14px -8px var(--success-shadow), + 0 1px 0 0 var(--success-strong); + border-radius: var(--r-sm, 4px); + margin: 6px 4px 14px 4px; + border-bottom: none; +} +.app-row-wrap.expanded .app-detail { + background: var(--app-expanded-bg, var(--canvas)); +} +.app-row-wrap.expanded + .app-row-wrap { + border-top: 1px solid var(--hairline); +} +.app-row-wrap.expanded .app-detail { + border-bottom-left-radius: var(--r-sm, 4px); + border-bottom-right-radius: var(--r-sm, 4px); +} + +/* App-Detail-Panel — max. 2 Spalten, lange Inhalte (Zuweisungen, + Detection, ...) spannen ueber beide via .app-detail-section--wide. */ +.app-detail { + padding: 14px 18px 18px 44px; + font-size: 12.5px; + color: var(--detail-text, var(--ink)); + background: var(--canvas); + border-top: 1px dashed var(--hairline-soft); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 28px; + align-items: start; +} +.app-detail .app-detail-section-title { + color: var(--detail-text-muted, var(--muted)); + border-bottom-color: var(--detail-border-soft, var(--hairline-soft)); +} +.app-detail .app-detail-lbl { + color: var(--detail-text-faint, var(--muted)); +} +.app-detail .app-detail-val { + color: var(--detail-text, var(--ink)); +} +.app-detail .app-detail-desc { + color: var(--detail-text, var(--ink)); +} +/* Mono-Felder (Datei, Setup-Pfad, Befehle), Copy-Buttons und Listen-Border + nutzen die abgeleiteten Overlay-Surfaces, damit sie zur gewaehlten Tint- + Farbe passen — egal ob User Dark/Light Mode oder Custom-Color verwendet. */ +.app-detail .app-detail-val.mono { + background: var(--detail-surface, var(--surface-soft)); + border-color: var(--detail-border-soft, var(--hairline-soft)); + color: var(--detail-text, var(--ink)); +} +.app-detail .app-detail-copy { + background: transparent; + border-color: var(--detail-border-soft, var(--hairline-soft)); + color: var(--detail-text-muted, var(--muted)); +} +.app-detail .app-detail-copy:hover { + background: var(--detail-surface, var(--surface-soft)); + border-color: var(--detail-border, var(--surface-strong)); + color: var(--detail-text, var(--ink)); +} +.app-detail .app-detail-list { color: var(--detail-text, var(--ink)); } +.app-detail .app-detail-notes { color: var(--detail-text-muted, var(--muted)); } + +/* Zuweisungs-Block innerhalb des Detail-Panels */ +.app-detail .asg-col { + background: var(--detail-surface, var(--surface)); + border-color: var(--detail-border-soft, var(--hairline)); +} +.app-detail .asg-col-head { + background: var(--detail-surface-strong, var(--surface-soft)); + border-bottom-color: var(--detail-border-soft, var(--hairline-soft)); +} +.app-detail .asg-row { border-bottom-color: var(--detail-border-soft, var(--hairline-soft)); } +.app-detail .asg-row:hover { background: var(--detail-surface, var(--surface-soft)); } +.app-detail .asg-name { color: var(--detail-text, var(--ink)); } +.app-detail .asg-row.native .asg-name { color: var(--detail-text-faint, var(--muted)); } +.app-detail .asg-native-tag { + background: var(--detail-surface, var(--surface-soft)); + color: var(--detail-text-muted, var(--muted)); + border-color: var(--detail-border-soft, var(--hairline)); +} +.app-detail .asg-empty { color: var(--detail-text-faint, var(--muted)); } +.app-detail .asg-del { color: var(--detail-text-muted, var(--muted)); } +.app-detail .asg-actions .entra-link { color: var(--detail-text-muted, var(--muted)); } +.app-detail .asg-actions .entra-link:hover { color: var(--detail-text, var(--ink)); } +@media (max-width: 1100px) { + .app-detail { grid-template-columns: 1fr; } +} +.app-detail-loading, +.app-detail-error, +.app-detail-empty { + grid-column: 1 / -1; + padding: 8px 0; + color: var(--muted); + font-size: 12.5px; + display: flex; + align-items: center; + gap: 8px; +} +.app-detail-error { color: var(--error); } +.app-detail-section { + min-width: 0; +} +.app-detail-section--wide { + grid-column: 1 / -1; +} +.app-detail-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin-bottom: 6px; + padding-bottom: 4px; + border-bottom: 1px solid var(--hairline-soft); +} +.app-detail-row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 8px; + padding: 3px 0; + align-items: baseline; +} +.app-detail-lbl { + color: var(--muted); + font-size: 11.5px; +} +.app-detail-val { + color: var(--ink); + word-break: break-word; + display: flex; + align-items: center; + gap: 6px; +} +.app-detail-val.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + background: var(--surface-soft); + padding: 2px 6px; + border-radius: var(--r-xs); + border: 1px solid var(--hairline-soft); + word-break: break-all; + overflow-wrap: anywhere; + min-width: 0; +} +.app-detail-copy { + appearance: none; + background: transparent; + border: 1px solid var(--hairline-soft); + color: var(--muted); + cursor: pointer; + width: 20px; height: 20px; + border-radius: var(--r-xs); + display: inline-grid; place-items: center; + font-size: 11px; + flex-shrink: 0; + transition: all var(--tx); +} +.app-detail-copy:hover { color: var(--ink); border-color: var(--surface-strong); background: var(--surface-soft); } +.app-detail-copy.copied { color: var(--success); border-color: var(--success-border); } +.app-detail-desc { + white-space: pre-wrap; + line-height: 1.5; + color: var(--ink); +} +.app-detail-notes { margin-top: 6px; font-size: 12px; color: var(--muted); } +.app-detail-list { + margin: 0; + padding-left: 18px; + list-style: disc; + color: var(--ink); +} +.app-detail-list li { + padding: 2px 0; + word-break: break-all; + overflow-wrap: anywhere; + line-height: 1.5; +} + +/* Apps ohne irgendeine Zuweisung — leicht roetlich, damit sofort auffaellt */ +.app-row.no-assignments .app-name { + color: var(--error); +} +.app-row.no-assignments .app-publisher { + color: var(--error); + opacity: 0.7; +} + +.col { display: flex; align-items: center; min-width: 0; } +.col-status { justify-content: center; } +/* col-name als Flex-Column wie urspruenglich, mit position:relative — die + Icons werden per absoluter Positionierung an die rechte Mitte gelegt, sodass + sie vertikal in der ganzen Zeilenhoehe zentriert sind (nicht nur in der + oberen Name-Line). */ +.col-name { flex-direction: column; align-items: stretch; min-width: 0; gap: 1px; justify-content: center; position: relative; padding-right: 50px; } +.col-source { justify-content: center; } +.col-type { justify-content: flex-start; } +.col-version { justify-content: flex-start; } +.col-avail { justify-content: stretch; } +.col-req { justify-content: stretch; } +.col-create { justify-content: stretch; } + +/* Buttons fuellen ihre Spalte komplett — saubere vertikale Linien. + Available/Required: Label klebt links, Count-Badge rechts, damit die + Schrift egal bei wievielen Gruppen immer an derselben Position startet. */ +.col-avail .type-btn, +.col-req .type-btn { + width: 100%; + justify-content: flex-start; + padding: 0 10px; +} +.col-create .type-btn { + width: 100%; + justify-content: center; + padding: 0; +} +.col-avail .type-btn > span:first-child, +.col-req .type-btn > span:first-child { + flex: 1 1 auto; + text-align: left; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.col-avail .type-btn > .type-btn-count, +.col-avail .type-btn > .type-btn-queued, +.col-req .type-btn > .type-btn-count, +.col-req .type-btn > .type-btn-queued { + flex-shrink: 0; +} + +.app-name-line { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; +} + +/* Aktions-Icons: absolut positioniert am rechten Rand der col-name, vertikal + ueber die ganze Zeilenhoehe (48 px) zentriert — egal ob die App nur einen + Namen hat oder Name + Publisher (zwei Zeilen). + Das padding-right: 50px auf col-name reserviert den Platz, damit lange + Namen/Publisher nicht unter die Icons rutschen. */ +.app-name-icons { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +/* Source-Logo: dezent, klein, klickbar */ +.app-source-link { + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: var(--r-sm); + text-decoration: none; + transition: background var(--tx); +} +.app-source-link:hover { background: var(--surface-soft); } + +.app-source { + width: 14px; + height: 14px; + object-fit: contain; + opacity: 0.7; + border-radius: 2px; + transition: opacity var(--tx); + display: block; +} +.app-source-link:hover .app-source { opacity: 1; } + +/* Letter-Fallback wenn das Vendor-Logo nicht ausgeliefert werden kann + (z.B. fehlende Datei). Wird per onerror in app.js eingehaengt. */ +.app-source.app-source-letter { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + color: var(--ink); + background: var(--surface-soft); + text-transform: uppercase; + line-height: 1; +} + +.cell-empty { + color: var(--surface-strong); + font-size: 12px; + font-weight: 500; +} + +.app-name { + font-size: 13.5px; + font-weight: 600; + color: var(--ink); + /* App-Namen NIE truncieren — bei Bedarf in mehrere Zeilen umbrechen. + overflow-wrap: anywhere greift auch fuer Namen ohne Leerzeichen. */ + white-space: normal; + overflow: visible; + overflow-wrap: anywhere; + word-break: normal; + letter-spacing: -0.01em; + min-width: 0; + text-decoration: none; + display: inline; + max-width: 100%; + line-height: 1.35; +} +a.app-name:hover { + color: var(--brand-accent); + text-decoration: underline; + text-underline-offset: 2px; +} +.app-row.no-assignments a.app-name { color: var(--error); } +.app-row.no-assignments a.app-name:hover { filter: brightness(0.9); } + +.app-meta { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.app-type { + font-size: 10px; + padding: 2px 7px; + border-radius: var(--r-xs); + background: var(--surface-card); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.4px; + font-weight: 600; + flex-shrink: 0; +} + +.app-version { + font-size: 10.5px; + padding: 2px 7px; + border-radius: var(--r-xs); + background: var(--surface-soft); + color: var(--body); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-weight: 500; + flex-shrink: 1; + min-width: 0; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; +} + +.app-publisher { + font-size: 12px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + max-width: 100%; + display: block; +} + +.row-status { + display: inline-grid; + place-items: center; + width: 18px; height: 18px; + font-size: 11px; + font-weight: 800; + flex-shrink: 0; + line-height: 1; + border-radius: 50%; + cursor: help; +} +.row-status.full { background: var(--success-soft); color: var(--success); } +.row-status.partial { background: var(--warning-soft); color: var(--warning); } + +.row-queued { + background: var(--ink); + color: var(--on-primary); + font-size: 10px; + font-weight: 700; + padding: 1px 7px; + border-radius: var(--r-pill); + flex-shrink: 0; + font-variant-numeric: tabular-nums; + letter-spacing: 0.2px; +} + +.app-row-actions { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 6px; + justify-content: flex-end; +} + +/* Sammel-Buttons "Available" / "Required" / "+" */ +.type-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 12px; + border-radius: var(--r-md); + font-size: 12px; + font-weight: 600; + font-family: inherit; + background: var(--canvas); + border: 1px solid var(--hairline); + color: var(--ink); + cursor: pointer; + transition: all var(--tx); + letter-spacing: -0.005em; + white-space: nowrap; + flex-shrink: 0; +} + +.type-btn:hover:not(:disabled) { + background: var(--surface-soft); + border-color: var(--ink); +} +.type-btn:active:not(:disabled) { transform: scale(0.98); } +.type-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +/* Subtile Intent-Faerbung */ +.type-available { color: var(--brand-accent); border-color: var(--accent-soft); } +.type-available:hover:not(:disabled) { background: var(--accent-bg); border-color: var(--brand-accent); } +.type-required { color: var(--pink); border-color: var(--pink-soft); } +.type-required:hover:not(:disabled) { background: var(--pink-soft); border-color: var(--pink); } + +/* Im Warenkorb → schwarz mit weisser Schrift */ +.type-btn.has-queued { + background: var(--ink); + border-color: var(--ink); + color: var(--on-primary); +} +.type-btn.has-queued:hover { background: var(--primary-active); border-color: var(--primary-active); } + +/* Mitgliedschafts-State */ +.type-btn.member-full { + background: var(--success-soft); + border-color: var(--success-border); + color: var(--success); +} +.type-btn.member-partial { + background: var(--warning-soft); + border-color: var(--warning-border); + color: var(--warning); +} + +/* Native-Only (All Users / All Devices) */ +.type-btn.native-only { + background: var(--surface-card); + border-color: var(--hairline); + color: var(--muted); + font-style: italic; +} + +/* "+" Button */ +.type-create { + width: 28px; + padding: 0; + font-size: 16px; + color: var(--muted); + border-style: dashed; + justify-content: center; +} +.type-create:hover:not(:disabled) { + color: var(--ink); + border-color: var(--ink); + border-style: solid; +} + +/* Anzahl-Hinweis (z.B. "Available 3" wenn 3 Available-Gruppen existieren) */ +.type-btn-count { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: var(--r-pill); + background: var(--surface-card); + color: var(--muted); + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.type-btn.has-queued .type-btn-count { + background: rgba(255,255,255,0.18); + color: var(--on-primary); +} +.type-btn.member-full .type-btn-count { background: var(--success-strong); color: var(--success); } +.type-btn.member-partial .type-btn-count { background: var(--warning-strong); color: var(--warning); } + +/* Im-Warenkorb-Counter */ +.type-btn-queued { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: var(--r-pill); + background: var(--on-primary); + color: var(--ink); + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +/* ============================================================= + GROUP PICKER POPOVER + ============================================================= */ + +.group-picker { + position: fixed; + z-index: 800; + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-md); + box-shadow: var(--shadow-lg); + min-width: 280px; + max-width: 420px; + overflow: hidden; + animation: picker-in 140ms cubic-bezier(0.2, 0.8, 0.3, 1); +} +@keyframes picker-in { + from { transform: translateY(-4px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.picker-head { + padding: 10px 14px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--hairline); + background: var(--surface-soft); +} + +.picker-list { + max-height: 320px; + overflow-y: auto; +} + +.picker-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: transparent; + border: none; + border-bottom: 1px solid var(--hairline-soft); + font-size: 13px; + font-family: inherit; + color: var(--ink); + text-align: left; + cursor: pointer; + transition: background var(--tx); +} +.picker-item:last-child { border-bottom: none; } +.picker-item:hover { background: var(--surface-soft); } + +.picker-item.queued { background: rgba(17,17,17,0.04); font-weight: 600; } + +.picker-check { + width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + flex-shrink: 0; + font-weight: 800; + color: var(--ink); +} + +.picker-name { + flex: 1; + word-break: break-all; + font-family: var(--font-mono); + font-size: 12px; +} + +.picker-mb { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: var(--r-xs); + letter-spacing: 0.3px; + flex-shrink: 0; +} +.picker-mb.full { background: var(--success-soft); color: var(--success); } +.picker-mb.partial { background: var(--warning-soft); color: var(--warning); } + +/* A/R Marker — sehr dezent, nur farbiger Punkt mit Buchstabe */ +.pill-group-marker { + display: inline-grid; + place-items: center; + width: 16px; height: 16px; + font-size: 9px; + font-weight: 700; + border-radius: var(--r-xs); + margin-right: 2px; + flex-shrink: 0; + letter-spacing: 0; +} +.pill-group-marker.avail { background: var(--accent-soft); color: var(--brand-accent); } +.pill-group-marker.req { background: var(--pink-soft); color: var(--pink); } + +.pill-divider { + width: 1px; + height: 16px; + background: var(--hairline); + margin: 0 6px; + flex-shrink: 0; +} + +/* "+ Required-Gruppe" — nur sichtbar bei Hover oder wenn Apps keine Required-Pillen hat */ +.pill-create { + opacity: 0; + transition: opacity var(--tx); +} +.app-row:hover .pill-create, +.app-row .pill-create.always-show { + opacity: 1; +} + +/* ============================================================= + PILLS — Cal.com style: white, hairline border, ink hover + ============================================================= */ + +.group-pill { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--r-sm); + font-size: 12px; + background: var(--canvas); + border: 1px solid var(--hairline); + color: var(--ink); + cursor: pointer; + transition: all var(--tx); + font-weight: 500; + max-width: 100%; + height: 24px; + line-height: 1; + letter-spacing: -0.005em; +} + +.group-pill.pill-available { + border-color: var(--accent-soft); +} +.group-pill.pill-required { + border-color: var(--pink-soft); +} + +.group-pill:hover:not(:disabled):not(.queued) { + border-color: var(--ink); + background: var(--surface-soft); + box-shadow: var(--shadow-xs); +} + +.group-pill.queued { + background: var(--ink); + border-color: var(--ink); + color: var(--on-primary); + font-weight: 600; + padding-left: 8px; +} +.group-pill.queued::before { + content: '✓'; + font-size: 11px; + font-weight: 700; + color: var(--on-primary); + margin-right: 2px; +} +.group-pill.queued:hover { background: var(--primary-active); } + +.group-pill.member-full { + background: var(--success-bg); + border-color: var(--success-border); + color: var(--success); +} +.group-pill.member-partial { + background: var(--warning-bg); + border-color: var(--warning-border); + color: var(--warning); +} + +.group-pill.native { + background: var(--surface-card); + border-color: var(--hairline); + color: var(--muted); + cursor: not-allowed; + font-style: italic; +} + +.group-pill.unknown { + background: var(--error-bg); + border: 1px dashed var(--error-border); + color: var(--muted); + cursor: not-allowed; + font-style: italic; +} + +.group-pill .pill-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; +} + +.group-pill .pill-ico { + font-size: 11px; + margin-left: 2px; + opacity: 0.85; +} + +.pill-create { + background: transparent; + border: 1px dashed var(--surface-strong); + color: var(--muted); + width: 24px; + padding: 0; + font-size: 14px; + font-weight: 600; + display: inline-grid; + place-items: center; + flex-shrink: 0; +} +.pill-create:hover { + border-color: var(--ink); + color: var(--ink); + border-style: solid; + background: var(--surface-soft); +} + +.no-assignments { + font-size: 12px; + color: var(--muted); + font-style: italic; +} + +/* ============================================================= + SIDE PANEL (Cart / Existing) + ============================================================= */ + +.side-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + border-bottom: 1px solid var(--hairline); +} + +.side-tab { + position: relative; + padding: 12px 14px; + background: transparent; + border: none; + color: var(--muted); + font-size: 13px; + font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: color var(--tx); + letter-spacing: -0.005em; + min-height: 56px; + line-height: 1.25; +} + +.side-tab:hover { color: var(--ink); } +.side-tab.active { color: var(--ink); font-weight: 600; } +.side-tab.active::after { + content: ''; + position: absolute; + inset: auto 0 -1px 0; + height: 2px; + background: var(--ink); +} + +.side-pane { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; } +.side-pane.hidden { display: none; } + +.side-pane-hint { + font-size: 12.5px; + color: var(--muted); + padding: 2px; + line-height: 1.5; +} + +.session-list { + flex: 1; + min-height: 120px; + max-height: 36vh; + overflow-y: auto; + border: 1px solid var(--hairline); + border-radius: var(--r-md); + background: var(--canvas); +} + +.session-item { + padding: 10px 12px; + border-bottom: 1px solid var(--hairline-soft); + display: flex; + align-items: center; + gap: 8px; + transition: background var(--tx); +} + +.session-item:hover { background: var(--surface-soft); } +.session-item:last-child { border-bottom: none; } + +.session-info { flex: 1; min-width: 0; } + +.session-app { + font-size: 13px; + font-weight: 600; + color: var(--ink); + word-break: break-word; + letter-spacing: -0.005em; +} + +.session-grp { + font-size: 11.5px; + color: var(--muted); + margin-top: 2px; + word-break: break-all; + font-family: var(--font-mono); +} + +.session-scope { + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.scope-tag { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: var(--r-xs); + letter-spacing: 0.2px; +} +.scope-tag.scope-dept { background: var(--teal-soft); color: var(--teal-deep); } +.scope-tag.scope-user { background: var(--violet-soft); color: var(--violet); } +.scope-target { + font-size: 11.5px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.session-type-badge { + font-size: 9px; + font-weight: 700; + padding: 2px 6px; + border-radius: var(--r-xs); + text-transform: uppercase; + flex-shrink: 0; + letter-spacing: 0.4px; +} + +.session-type-badge.avail { background: var(--accent-soft); color: var(--brand-accent); } +.session-type-badge.req { background: var(--pink-soft); color: var(--pink); } + +.session-remove { + background: transparent; + border: none; + color: var(--muted); + width: 24px; height: 24px; + border-radius: var(--r-sm); + font-size: 17px; + flex-shrink: 0; + display: grid; place-items: center; + transition: all var(--tx); +} +.session-remove:hover { background: var(--error-bg); color: var(--error); } + +.session-summary { + background: var(--surface-soft); + border-radius: var(--r-md); + padding: 12px 14px; +} + +.summary-section { + margin-bottom: 10px; +} +.summary-section:last-of-type { margin-bottom: 4px; } + +.summary-section-title { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + color: var(--muted); + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.summary-row { + display: flex; + justify-content: space-between; + font-size: 13px; + padding: 2px 0; + color: var(--body); +} + +.summary-row.sub { + font-size: 12.5px; + padding-left: 4px; +} + +.summary-row span:last-child { + color: var(--ink); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.summary-row.total { + border-top: 1px solid var(--hairline); + margin-top: 8px; + padding-top: 10px; + font-size: 14px; + font-weight: 700; + color: var(--ink); +} + +.summary-row.total span:last-child { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; +} + +/* ============================================================= + EXISTING list + ============================================================= */ + +.existing-list { + flex: 1; + overflow-y: auto; + border: 1px solid var(--hairline); + border-radius: var(--r-md); + background: var(--canvas); + min-height: 120px; +} + +.existing-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--hairline-soft); +} +.existing-item:last-child { border-bottom: none; } + +.existing-app { + flex: 1; + font-size: 12.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: var(--ink); +} + +.existing-flags { display: flex; gap: 4px; flex-shrink: 0; } + +.exist-flag { + font-size: 9px; + font-weight: 700; + padding: 2px 6px; + border-radius: var(--r-xs); + letter-spacing: 0.3px; +} + +.exist-flag.a { background: var(--accent-soft); color: var(--brand-accent); } +.exist-flag.r { background: var(--pink-soft); color: var(--pink); } +.exist-flag.n { background: var(--surface-card); color: var(--muted); } + +/* ============================================================= + EMPTY STATE + ============================================================= */ + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--muted); + font-size: 13px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.empty-state.small { padding: 24px 16px; font-size: 12px; } + +.empty-ico { font-size: 28px; opacity: 0.5; } + +.empty-title { + color: var(--ink); + font-weight: 600; + font-size: 14px; + letter-spacing: -0.01em; +} + +.empty-text { + font-size: 13px; + color: var(--muted); + max-width: 280px; + line-height: 1.55; +} + +/* ============================================================= + ONBOARDING — Device-Code Login + ============================================================= */ + +.onboarding { + flex: 1; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 80px 24px 40px; + background: var(--canvas); +} + +.onboarding.hidden { display: none; } + +.onboarding-card { + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-xl); + padding: 56px 56px 48px; + max-width: 540px; + width: 100%; + text-align: center; + box-shadow: var(--shadow-md); +} + +.onboarding-icon { + width: 64px; + height: 64px; + margin: 0 auto 20px; + display: grid; + place-items: center; + background: var(--ink); + color: var(--on-primary); + border-radius: var(--r-lg); +} + +.onboarding-icon-error { + background: var(--error-soft); + color: var(--error); +} + +.onboarding-title { + font-size: 28px; + font-weight: 600; + letter-spacing: -0.025em; + color: var(--ink); + margin-bottom: 12px; + line-height: 1.2; +} + +.onboarding-text { + color: var(--body); + font-size: 15px; + line-height: 1.55; + margin-bottom: 24px; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +.onboarding-meta { + margin-top: 24px; + font-size: 12px; + color: var(--muted); + line-height: 1.55; + padding-top: 24px; + border-top: 1px solid var(--hairline-soft); +} + +/* Device-Code-Block */ +.dc-step { + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.dc-code-block { + display: flex; + align-items: center; + gap: 12px; + justify-content: center; + margin: 8px 0 16px; +} + +.dc-code { + font-family: var(--font-mono); + font-size: 28px; + font-weight: 600; + letter-spacing: 0.15em; + background: var(--surface-soft); + border: 1px solid var(--hairline); + border-radius: var(--r-md); + padding: 14px 22px; + color: var(--ink); + user-select: all; +} + +.dc-copy { flex-shrink: 0; } + +.dc-open-btn { + margin: 4px 0 8px; + text-decoration: none; +} +.dc-open-btn:hover { text-decoration: none; } + +.dc-waiting { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 16px; + padding: 10px 16px; + background: var(--surface-soft); + border-radius: var(--r-pill); + font-size: 13px; + color: var(--muted); + font-weight: 500; +} + +.dc-spinner-wrap { display: flex; justify-content: center; margin-bottom: 24px; } + +/* ============================================================= + TOASTS + ============================================================= */ + +.toasts { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 1000; + pointer-events: none; +} + +.toast { + pointer-events: auto; + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-md); + padding: 12px 14px; + font-size: 13px; + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 280px; + max-width: 420px; + box-shadow: var(--shadow-md); + animation: slide-in 280ms cubic-bezier(0.2, 0.8, 0.2, 1); + border-left: 3px solid var(--ink); +} + +.toast.ok { border-left-color: var(--success); } +.toast.warn { border-left-color: var(--warning); } +.toast.err { border-left-color: var(--error); } + +.toast-ico { + flex-shrink: 0; + width: 22px; height: 22px; + border-radius: 50%; + display: grid; place-items: center; + font-size: 12px; + font-weight: 700; + background: var(--surface-card); + color: var(--ink); +} +.toast.ok .toast-ico { background: var(--success-soft); color: var(--success); } +.toast.warn .toast-ico { background: var(--warning-soft); color: var(--warning); } +.toast.err .toast-ico { background: var(--error-soft); color: var(--error); } + +.toast-content { flex: 1; min-width: 0; } +.toast-title { font-weight: 600; margin-bottom: 2px; color: var(--ink); } +.toast-msg { font-size: 12.5px; color: var(--body); word-wrap: break-word; } + +@keyframes slide-in { + from { transform: translateX(420px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ============================================================= + LOADING OVERLAY + ============================================================= */ + +.loading { + position: fixed; + inset: 0; + background: rgba(255,255,255,0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + animation: fade-in 200ms ease-out; +} + +.loading-card { + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-lg); + padding: 28px 32px; + display: flex; + align-items: center; + gap: 14px; + box-shadow: var(--shadow-lg); + min-width: 260px; +} + +.spinner { + width: 24px; height: 24px; + border-radius: 50%; + border: 2.5px solid var(--surface-strong); + border-top-color: var(--ink); + animation: spin 800ms linear infinite; + flex-shrink: 0; +} + +.spinner-sm { + width: 14px; height: 14px; + border-width: 2px; +} + +.loading-text { + font-size: 14px; + color: var(--ink); + font-weight: 500; +} + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + +/* Skeleton */ +.skeleton { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} +.sk-line { + height: 12px; + background: linear-gradient(90deg, var(--surface-soft) 0%, var(--surface-card) 50%, var(--surface-soft) 100%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: var(--r-xs); +} +.sk-line.short { width: 40%; } +.sk-line.med { width: 70%; } +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ============================================================= + MODALS + ============================================================= */ + +.modal { + position: fixed; + inset: 0; + z-index: 1500; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 200ms ease-out; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(17,17,17,0.4); + backdrop-filter: blur(4px); +} + +.modal-box { + position: relative; + background: var(--canvas); + border-radius: var(--r-lg); + width: 92%; + max-width: 500px; + box-shadow: var(--shadow-lg); + animation: modal-in 240ms cubic-bezier(0.2, 0.8, 0.3, 1); + overflow: hidden; +} + +.modal-box.modal-lg { max-width: 720px; } +.modal-box.modal-xl { max-width: 1320px; } + +@keyframes modal-in { + from { transform: scale(0.96) translateY(-8px); opacity: 0; } + to { transform: scale(1) translateY(0); opacity: 1; } +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--hairline); +} + +.modal-head h3 { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.015em; + color: var(--ink); +} + +.modal-close { + background: transparent; + border: none; + color: var(--muted); + font-size: 22px; + width: 32px; height: 32px; + border-radius: var(--r-sm); + transition: all var(--tx); + display: grid; place-items: center; + line-height: 1; +} +.modal-close:hover { background: var(--surface-soft); color: var(--ink); } + +.modal-body { + padding: 24px; + /* Hoch genug damit gaengige Modal-Inhalte (Settings, Create-Assignment) + ohne Scrollen passen. Bei extrem langen Inhalten oder kleinen Displays + greift der Scroll als Fallback. */ + max-height: 85vh; + overflow-y: auto; +} + +.modal-foot { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 24px; + border-top: 1px solid var(--hairline); + background: var(--surface-soft); +} + +.form-group { margin-bottom: 16px; } +.form-group .lbl { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; + font-weight: 600; + letter-spacing: -0.005em; +} +.form-group .input { width: 100%; } + +.form-hint { + font-size: 12px; + margin-top: 6px; + color: var(--muted); + font-weight: 500; +} +.form-hint.ok { color: var(--success); } +.form-hint.err { color: var(--error); } + +.confirm-text { + font-size: 15px; + margin-bottom: 16px; + line-height: 1.55; + color: var(--body); +} +.confirm-text strong { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; } + +.confirm-warn { + font-size: 13px; + color: var(--body); + background: var(--surface-soft); + border: 1px solid var(--hairline); + border-left: 3px solid var(--warning); + border-radius: var(--r-md); + padding: 12px 14px; +} + +/* Result Modal */ + +.res-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-radius: var(--r-md); + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + border: 1px solid; +} +.res-banner-ok { background: var(--success-soft); border-color: var(--success-shadow); color: var(--success); } +.res-banner-warn { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); } +.res-banner-err { background: var(--error-soft); border-color: var(--error-border); color: var(--error); } + +.res-banner-ico { + display: inline-grid; + place-items: center; + width: 28px; height: 28px; + border-radius: 50%; + font-size: 16px; + font-weight: 800; + color: #fff; + flex-shrink: 0; + line-height: 1; +} +.res-banner-ok .res-banner-ico { background: var(--success); } +.res-banner-warn .res-banner-ico { background: var(--warning); } +.res-banner-err .res-banner-ico { background: var(--error); } + +.result-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 18px; +} + +.rstat { + background: var(--surface-card); + border-radius: var(--r-md); + padding: 16px 12px; + text-align: center; + display: flex; + flex-direction: column; + gap: 4px; +} + +.rstat .num { + font-size: 32px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.025em; + line-height: 1; + color: var(--ink); +} + +.rstat .lbl { + font-size: 11px; + text-transform: uppercase; + color: var(--muted); + letter-spacing: 0.5px; + font-weight: 600; +} + +.rstat.ok .num { color: var(--success); } +.rstat.warn .num { color: var(--warning); } +.rstat.err .num { color: var(--error); } +.rstat.tot .num { color: var(--ink); } + +/* Detail-Liste */ +.res-details { margin-bottom: 16px; } +.res-details-head { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted); + margin-bottom: 8px; + padding: 0 2px; +} +.res-details-list { + border: 1px solid var(--hairline); + border-radius: var(--r-md); + max-height: 320px; + overflow-y: auto; + background: var(--canvas); +} +.res-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid var(--hairline-soft); +} +.res-row:last-child { border-bottom: none; } + +.res-row-ico { + display: inline-grid; + place-items: center; + width: 20px; height: 20px; + border-radius: 50%; + font-size: 12px; + font-weight: 800; + flex-shrink: 0; + margin-top: 1px; +} +.res-row-ok .res-row-ico { background: var(--success-soft); color: var(--success); } +.res-row-warn .res-row-ico { background: var(--warning-soft); color: var(--warning); } +.res-row-err .res-row-ico { background: var(--error-soft); color: var(--error); } + +.res-row-main { flex: 1; min-width: 0; } +.res-row-line1 { + font-size: 13.5px; + color: var(--ink); + word-break: break-word; +} +.res-row-line1 strong { font-weight: 700; } +.res-row-line2 { + font-size: 11.5px; + color: var(--muted); + font-family: var(--font-mono); + margin-top: 2px; + word-break: break-all; +} +.res-row-type { + font-family: var(--font-body); + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + border-radius: var(--r-xs); + background: var(--surface-card); + margin-left: 4px; + vertical-align: middle; +} +.res-row-msg { + font-size: 11.5px; + color: var(--body); + margin-top: 4px; + font-style: italic; +} + +.report-link { + text-align: center; + padding: 8px; +} + +.report-link .btn { + text-decoration: none; + display: inline-flex; +} + +.res-debug { margin-top: 12px; } +.res-debug details { + background: var(--surface-soft); + border: 1px solid var(--hairline); + border-radius: var(--r-md); + padding: 8px 12px; +} +.res-debug summary { + font-size: 11px; + color: var(--muted); + cursor: pointer; + user-select: none; + font-weight: 500; +} +.res-debug pre { + margin-top: 8px; + font-family: var(--font-mono); + font-size: 11px; + background: var(--canvas); + border: 1px solid var(--hairline); + border-radius: var(--r-sm); + padding: 10px; + overflow-x: auto; + color: var(--ink); +} + +/* ============================================================= + RESPONSIVE + ============================================================= */ + +@media (max-width: 1280px) { + .stepper { display: none; } + .topbar { grid-template-columns: auto 1fr auto; } +} + +@media (max-width: 1100px) { + .layout { grid-template-columns: 1fr; } + .panel { position: static; max-height: none; } + .panel-side { order: 99; } +} + +@media (max-width: 640px) { + .topbar { padding: 0 14px; height: auto; flex-wrap: wrap; gap: 12px; padding-block: 12px; } + .layout { padding: 12px; } + .mode-segment { grid-template-columns: 1fr; } + .result-stats { grid-template-columns: 1fr 1fr; } + .apps-toolbar { flex-direction: column; } + .apps-toolbar .select { width: 100%; } + .onboarding-card { padding: 32px 24px; } + .dc-code { font-size: 22px; padding: 12px 16px; } +} + +/* ============================================================= + Erweiterungen 2026-05-18: + - dezente Link-Icons neben Namen + - App-Delete-Button + - Picker mit Side-Actions (Entra, Members-Tab, Delete-Assignment) + - Standalone Members-Seite + ============================================================= */ + +/* Name-Spalten: nicht mehr klickbar, kleines Icon daneben */ +.target-name .target-text { + display: inline; + color: inherit; +} +.target-link-icons { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + margin-left: 4px; +} +.target-row .col-create { /* (no-op, just keeps cascade clean) */ } + +.app-name-line .entra-link { + margin-left: 2px; +} +.ex-member-name .entra-link { + margin-left: 4px; +} + +/* App-Delete-Button (Spalte ganz rechts) */ +.col-delete { justify-content: center; } +.app-delete-btn { + width: 24px; + height: 24px; + display: inline-grid; + place-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--r-sm); + color: var(--muted); + cursor: pointer; + opacity: 0; + transition: all var(--tx); + padding: 0; + flex-shrink: 0; +} +.app-row:hover .app-delete-btn { opacity: 0.7; } +.app-delete-btn:hover { + opacity: 1; + color: var(--error); + background: var(--error-soft, rgba(220, 38, 38, 0.12)); + border-color: var(--error); +} +.app-delete-btn.app-delete-locked { + cursor: not-allowed; + opacity: 0.35; +} +.app-row:hover .app-delete-btn.app-delete-locked { opacity: 0.55; } +.app-delete-btn.app-delete-locked:hover { + color: var(--muted); + background: var(--surface-soft); + border-color: var(--hairline); +} + +/* Inline-Edit-Icon neben dem App-Namen (Pencil). PMPC-Apps: passives Lock. */ +.app-rename-btn { + background: transparent; + border: 1px solid transparent; + padding: 0; + cursor: pointer; + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: var(--r-sm); + color: var(--muted); + opacity: 0; + transition: all var(--tx); + flex-shrink: 0; +} +.app-row:hover .app-rename-btn { opacity: 0.65; } +.app-rename-btn:hover { + opacity: 1; + color: var(--brand-accent); + background: var(--surface-soft); + border-color: var(--hairline); +} +.app-rename-locked { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: var(--r-sm); + color: var(--muted); + flex-shrink: 0; + opacity: 0; + cursor: not-allowed; +} +.app-row:hover .app-rename-locked { opacity: 0.4; } + +/* Rename-Modal */ +.ra-old-name { + font-size: 13px; + color: var(--muted); + padding: 6px 0; + word-break: break-word; + font-style: italic; +} +.form-hint-err { + color: var(--error); + font-weight: 500; + min-height: 1.2em; + margin-top: 4px; +} + +/* Group-Picker: jede Zeile bekommt Side-Actions rechts */ +.picker-row { + display: flex; + align-items: stretch; + gap: 2px; + border-bottom: 1px solid var(--hairline-soft); +} +.picker-row:last-child { border-bottom: none; } +.picker-row.queued { background: rgba(17, 17, 17, 0.04); font-weight: 600; } +.picker-row .picker-item { border-bottom: none; flex: 1; } + +.picker-actions { + display: inline-flex; + align-items: center; + gap: 2px; + padding-right: 8px; + flex-shrink: 0; +} +.picker-actions .entra-link { + opacity: 0.55; +} +.picker-row:hover .picker-actions .entra-link { opacity: 1; } + +.picker-del { + width: 22px; + height: 22px; + display: inline-grid; + place-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--r-sm); + color: var(--muted); + cursor: pointer; + opacity: 0.55; + transition: all var(--tx); + padding: 0; +} +.picker-row:hover .picker-del { opacity: 0.85; } +.picker-del:hover { + opacity: 1; + color: var(--error); + background: var(--error-soft, rgba(220, 38, 38, 0.12)); + border-color: var(--error); +} + +/* Members-Standalone-Seite */ +body.members-page { + background: var(--canvas); +} +.members-page .topbar .brand-name { font-weight: 600; } +.members-main { + max-width: 980px; + margin: 28px auto; + padding: 0 16px 80px 16px; +} +.members-card { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--r-lg); + overflow: hidden; + box-shadow: var(--shadow-soft, 0 1px 3px rgba(0,0,0,0.04)); +} +.members-head { + padding: 18px 22px 14px 22px; + display: flex; + flex-direction: column; + gap: 6px; + border-bottom: 1px solid var(--hairline-soft); +} +.members-title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.015em; + margin: 0; + color: var(--ink); + word-break: break-word; +} +.members-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.meta-mono { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 11.5px; + background: var(--surface-soft); + color: var(--muted); + padding: 2px 8px; + border-radius: var(--r-xs); + border: 1px solid var(--hairline); +} +.members-toolbar { + display: flex; + gap: 8px; + align-items: center; + padding: 12px 22px; + border-bottom: 1px solid var(--hairline-soft); + background: var(--surface-soft); +} +.members-toolbar .search-wrap { flex: 1; } + +.members-list { + max-height: 70vh; + overflow-y: auto; +} +.m-row { + padding: 10px 22px; + border-bottom: 1px solid var(--hairline-soft); + transition: background var(--tx); +} +.m-row:last-child { border-bottom: none; } +.m-row:hover { background: var(--surface-soft); } +.m-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.m-name { + font-size: 13.5px; + color: var(--ink); + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; +} +.m-name .entra-link { opacity: 0.5; } +.m-row:hover .m-name .entra-link { opacity: 1; } +.m-upn { + font-size: 12px; + color: var(--muted); +} +.m-loading { + padding: 32px 22px; + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 13px; + justify-content: center; +} + +/* Picker-Item soll innerhalb der Row keinen Rand-Radius brechen */ +.picker-row .picker-item { border-radius: 0; } +.picker-row:first-child .picker-item { border-top-left-radius: var(--r-md); } +.picker-row:last-child .picker-item { border-bottom-left-radius: var(--r-md); } + +/* Zuweisungen-Sektion im aufgeklappten App-Detail */ +.asg-section .asg-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 8px; +} +@media (max-width: 900px) { .asg-section .asg-grid { grid-template-columns: 1fr; } } +.asg-col { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--r-sm); + overflow: hidden; +} +.asg-col-head { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--surface-soft); + border-bottom: 1px solid var(--hairline-soft); +} +.asg-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + padding: 2px 7px; + border-radius: var(--r-xs); +} +.asg-badge.avail { background: var(--accent-soft, rgba(99,102,241,0.15)); color: var(--brand-accent); } +.asg-badge.req { background: var(--pink-soft, rgba(236,72,153,0.15)); color: var(--pink, #ec4899); } +.asg-count { + font-size: 11px; + color: var(--muted); + font-variant-numeric: tabular-nums; + margin-left: auto; +} +.asg-list { + list-style: none; + margin: 0; + padding: 0; +} +.asg-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--hairline-soft); + transition: background var(--tx); +} +.asg-row:last-child { border-bottom: none; } +.asg-row:hover { background: var(--surface-soft); } +.asg-name { + flex: 1; + min-width: 0; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 11.5px; + color: var(--ink); + /* nicht buchstabenweise brechen — overflow-wrap erlaubt nur Notbruch */ + overflow-wrap: anywhere; + word-break: normal; + line-height: 1.45; +} +.asg-row.native .asg-name { color: var(--muted); font-style: italic; } +.asg-native-tag { + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 1px 6px; + border-radius: var(--r-xs); + background: var(--surface-soft); + color: var(--muted); + border: 1px solid var(--hairline); +} +.asg-mb { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: var(--r-xs); + letter-spacing: 0.3px; + flex-shrink: 0; +} +.asg-mb.full { background: var(--success-soft); color: var(--success); } +.asg-mb.partial { background: var(--warning-soft, rgba(245,158,11,0.15)); color: var(--warning, #f59e0b); } +.asg-actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} +.asg-actions .entra-link { opacity: 0.55; } +.asg-row:hover .asg-actions .entra-link { opacity: 1; } +.asg-del { + width: 22px; + height: 22px; + display: inline-grid; + place-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--r-sm); + color: var(--muted); + cursor: pointer; + opacity: 0.55; + transition: all var(--tx); + padding: 0; +} +.asg-row:hover .asg-del { opacity: 0.85; } +.asg-del:hover { + opacity: 1; + color: var(--error); + background: var(--error-soft, rgba(220,38,38,0.12)); + border-color: var(--error); +} +.asg-empty { + padding: 10px 12px; + font-size: 12px; + color: var(--muted); + font-style: italic; +} + +/* ============================================================= + Einstellungen-Modal + ============================================================= */ +.icon-btn-header { + width: 32px; + height: 32px; + border-radius: var(--r-sm); +} +.icon-btn-header svg { display: block; } +/* Disconnect-Button: Hover-Akzent in Fehlerfarbe damit "Trennen" als + destruktive Aktion sofort erkennbar ist. */ +#btnDisconnect:hover { + color: var(--error); + background: var(--error-bg); + border-color: transparent; +} + +.settings-form { display: flex; flex-direction: column; gap: 18px; } +.settings-sec { + border: 1px solid var(--hairline); + background: var(--surface); + border-radius: var(--r-md); + padding: 14px 16px; +} +.settings-sec.settings-sec-meta { background: var(--surface-soft); } + +/* First-Run / Setup-Required-Banner — wird im Settings-Modal-Body und im + Onboarding-Panel verwendet. Optisch warnend, aber dezent (kein rot). */ +.banner-warning { + border: 1px solid var(--warning, #f59e0b); + background: color-mix(in srgb, var(--warning, #f59e0b) 12%, var(--surface)); + color: var(--ink); + border-radius: var(--r-md); + padding: 10px 14px; + margin: 0 0 14px 0; + font-size: 12.5px; + line-height: 1.45; +} +.banner-warning strong { font-weight: 700; } +.banner-warning a { color: var(--accent); font-weight: 600; } +.banner-warning a:hover { text-decoration: underline; } + +/* Setup-Datei aktualisieren */ +.content-update-row { display: flex; flex-direction: column; gap: 6px; } +.uc-path-row { display: flex; gap: 8px; align-items: stretch; } +.uc-path-row .input { flex: 1; min-width: 0; } +.uc-path-row .btn { flex-shrink: 0; white-space: nowrap; } +.content-update-progress { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 8px 12px; + border-radius: var(--r-md); + background: var(--surface-soft); + border: 1px solid var(--hairline-soft); + font-size: 12.5px; + color: var(--ink); +} + +/* Konfig-Test-Ergebnisse im Settings-Modal — eine Zeile pro Pruefung, + farbliche Statuspille links. */ +.settings-test-result { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.set-test-line { + font-size: 12.5px; + line-height: 1.5; + color: var(--ink); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; +} +.set-test-line code { font-size: 11.5px; } +.set-test-icon { + display: inline-block; + min-width: 36px; + padding: 1px 6px; + border-radius: var(--r-sm); + font-size: 10.5px; + font-weight: 700; + text-align: center; + letter-spacing: 0.04em; +} +.set-test-icon.ok { background: color-mix(in srgb, var(--success, #10b981) 18%, var(--surface)); color: var(--success, #10b981); } +.set-test-icon.fail { background: color-mix(in srgb, var(--error, #ef4444) 18%, var(--surface)); color: var(--error, #ef4444); } +.set-test-icon.warn { background: color-mix(in srgb, var(--warning, #f59e0b) 18%, var(--surface)); color: var(--warning, #f59e0b); } +.set-test-line.set-test-pending { color: var(--ink-muted); } +.set-test-line.set-test-warn { color: var(--ink-muted); } +.set-test-sample { color: var(--ink-muted); font-size: 11.5px; } +.settings-sec-head { margin-bottom: 10px; } +.settings-sec-head-row { + display: flex; + align-items: flex-start; + gap: 12px; + justify-content: space-between; +} +.settings-sec-head-row > div:first-child { flex: 1; min-width: 0; } +.settings-sec-head-row .btn { flex-shrink: 0; white-space: nowrap; } +.settings-sec-head h4 { + margin: 0 0 2px 0; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.005em; + color: var(--ink); + text-transform: none; +} +.settings-sec-sub { + font-size: 11.5px; + color: var(--muted); + line-height: 1.45; +} +.settings-sec-sub code { + font-family: var(--font-mono); + font-size: 11px; + background: var(--surface-soft); + padding: 1px 5px; + border-radius: var(--r-xs); + color: var(--body); +} +.settings-form .form-group { margin-top: 8px; } +.settings-form .form-group:first-child { margin-top: 0; } +.settings-form .lbl { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--muted); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.4px; +} +.settings-form .input { + width: 100%; + padding: 8px 10px; + font-size: 13px; +} +.settings-form textarea.input { + resize: vertical; + min-height: 80px; + line-height: 1.5; +} +.settings-form .input.mono { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 12.5px; +} +.settings-form .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +@media (max-width: 600px) { + .settings-form .form-row { grid-template-columns: 1fr; } +} +.settings-form .form-check-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 6px; +} +.settings-form .form-hint { + font-size: 11.5px; + color: var(--muted); + margin-top: 6px; +} +.settings-form .set-preview { + font-family: var(--font-mono); + font-size: 12px; + background: var(--surface-soft); + padding: 2px 7px; + border-radius: var(--r-xs); + color: var(--ink); + border: 1px solid var(--hairline); +} +.settings-form .set-path { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + word-break: break-all; +} +.modal-foot-spacer { flex: 1; } + +/* Branding: Logo-Auswahl */ +.logo-row { + display: flex; + align-items: center; + gap: 16px; +} +.logo-preview { + width: 56px; height: 56px; + border-radius: var(--r-md); + background: var(--brand-primary); + color: var(--on-primary); + display: grid; place-items: center; + font-weight: 700; + font-size: 22px; + overflow: hidden; + flex-shrink: 0; + border: 1px solid var(--hairline); +} +.logo-preview:has(.logo-preview-img) { background: var(--surface-soft); } +.logo-preview-img { + width: 100%; height: 100%; + object-fit: contain; + display: block; +} +.logo-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} +.logo-actions .btn { white-space: nowrap; } +.input.hidden { display: none; } + +/* Farben-Grid */ +.color-grid { display: flex; flex-direction: column; gap: 6px; } +.color-row { + display: grid; + grid-template-columns: 22px 1fr 36px 92px 28px; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--hairline); + border-radius: var(--r-sm); + background: var(--surface); + transition: border-color var(--tx); +} +.color-row:hover { border-color: var(--surface-strong); } +.color-swatch { + width: 22px; height: 22px; + border-radius: var(--r-xs); + border: 1px solid var(--hairline); + display: block; + background: var(--ink); +} +.color-meta { min-width: 0; } +.color-name { + font-size: 12.5px; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.color-desc { + font-size: 11px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.color-picker { + width: 36px; + height: 28px; + padding: 0; + border: 1px solid var(--hairline); + border-radius: var(--r-xs); + background: transparent; + cursor: pointer; +} +.color-picker::-webkit-color-swatch-wrapper { padding: 2px; } +.color-picker::-webkit-color-swatch { border: none; border-radius: 3px; } +.color-hex { + font-size: 12px !important; + padding: 4px 8px !important; + text-align: center; + width: 92px; +} +.color-row .link-btn { + font-size: 16px; + line-height: 1; + color: var(--muted); + background: transparent; + border: none; + cursor: pointer; + padding: 4px 6px; + border-radius: var(--r-xs); +} +.color-row .link-btn:hover { color: var(--ink); background: var(--surface-soft); } + +@media (max-width: 640px) { + .color-row { + grid-template-columns: 22px 1fr 36px; + grid-template-rows: auto auto; + gap: 6px 10px; + } + .color-row .color-hex { grid-column: 1 / 4; width: 100%; } + .color-row .link-btn { grid-column: 1 / 4; justify-self: flex-end; } +} + +/* Statische entra-Links (z.B. in der Members-Seite, der App-Zeile, + im Group-Picker). */ +.entra-link-static, +.app-name-line .entra-link, +.picker-actions .entra-link, +.m-name .entra-link { + opacity: 0.55; +} +.app-row:hover .app-name-line .entra-link { + opacity: 1; +} +/* Empfaenger-Liste (links): Icons sind IMMER voll sichtbar — egal ob + Abteilung, RPA-Gruppe oder Einzel-User. Vorher hover-only -> verwirrend. */ +.target-link-icons .entra-link { + opacity: 1; +} + +/* ============================================================= + Help-Modal — TOC-Sidebar links, gerenderter Markdown-Content rechts + ============================================================= */ +.help-body { + display: grid; + grid-template-columns: 260px 1fr; + gap: 28px; + padding: 32px 28px 24px 28px; + max-height: 80vh; + overflow: hidden; +} +@media (max-width: 800px) { + .help-body { grid-template-columns: 1fr; } + .help-toc { display: none; } +} +.help-toc { + position: sticky; + top: 0; + align-self: start; + max-height: 80vh; + overflow-y: auto; + padding: 0 12px 0 4px; + border-right: 1px solid var(--hairline-soft); +} +.help-toc-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin-bottom: 16px; +} +.help-toc ul { + list-style: none; + margin: 0; + padding: 0; +} +.help-toc li { + margin: 2px 0; +} +.help-toc a { + display: block; + padding: 6px 12px; + border-radius: var(--r-sm); + font-size: 12.5px; + color: var(--body); + text-decoration: none; + transition: background var(--tx), color var(--tx); +} +.help-toc a:hover { + background: var(--surface-soft); + color: var(--ink); +} +.help-toc a.active { + background: var(--accent-soft); + color: var(--brand-accent); + font-weight: 600; +} +.help-toc a.active:hover { + background: var(--accent-soft); + color: var(--brand-accent); +} +.help-content { + overflow-y: auto; + max-height: 80vh; + padding-right: 12px; +} +.help-content h1:first-child { + margin-top: 0; + margin-bottom: 14px; +} +.help-content h2 { + padding-top: 14px; + border-top: 1px solid var(--hairline-soft); + transition: background var(--tx); + border-radius: var(--r-sm); +} +.help-content h2:first-of-type { + border-top: none; + padding-top: 0; +} +/* Flash beim TOC-Klick */ +.help-h-flash { + background: var(--accent-soft); + padding-left: 8px; + padding-right: 8px; + margin-left: -8px; + margin-right: -8px; + animation: helpHFlash 1.2s ease-out; +} +@keyframes helpHFlash { + 0%, 100% { background: var(--accent-soft); } + 50% { background: transparent; } +} +.help-loading { + padding: 40px 20px; + text-align: center; + color: var(--muted); + font-size: 13px; +} +.help-error { + padding: 16px 20px; + background: var(--error-soft); + color: var(--error); + border: 1px solid var(--error-border); + border-radius: var(--r-md); +} +.help-version-badge { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + padding: 4px 10px; + background: var(--surface-soft); + border: 1px solid var(--hairline-soft); + border-radius: var(--r-pill); +} +/* Help-Modal: padding kommt komplett aus .help-body. KEINE eigene Regel + fuer #modalHelp .modal-body — die wuerde wegen ID-Specificity das + .help-body-Padding aushebeln. */ + +/* WICHTIG: das Help-Modal-Content-Div hat BEIDE Klassen am selben Element + (
    ), deshalb COMPOUND-Selector + (.help-content.md-content) — Descendant-Selector mit Leerzeichen wuerde + NICHT matchen. --detail-text wird global gesetzt (helle Panel-Farbe -> + dunkler readableForeground) und macht die md-content-Headings im Dark + Mode unlesbar. Wir overriden hier mit den Basis-Vars. !important + forciert Sieg gegen die generische .md-content-Regel — Specificity + alleine reicht nicht, weil die Help-/Detail-Regeln gemischt liegen. */ +.help-content.md-content { color: var(--ink) !important; } +.help-content.md-content h1, +.help-content.md-content h2, +.help-content.md-content h4 { color: var(--ink) !important; } +.help-content.md-content h3, +.help-content.md-content h5, +.help-content.md-content h6 { color: var(--muted) !important; } +.help-content.md-content p, +.help-content.md-content li, +.help-content.md-content td { color: var(--ink) !important; } +.help-content.md-content del { color: var(--muted) !important; } +.help-content.md-content blockquote { + color: var(--muted) !important; + border-left-color: var(--hairline) !important; + background: var(--surface-soft) !important; +} +.help-content.md-content code { + background: var(--surface-soft) !important; + border-color: var(--hairline-soft) !important; + color: var(--ink) !important; +} +.help-content.md-content pre, +.help-content.md-content th { + background: var(--surface-soft) !important; + border-color: var(--hairline-soft) !important; + color: var(--ink) !important; +} +.help-content.md-content th, +.help-content.md-content td { border-color: var(--hairline-soft) !important; } +.help-content.md-content tr:nth-child(even) td { background: var(--surface-soft) !important; } +.help-content.md-content hr { border-top-color: var(--hairline-soft) !important; } + +/* ============================================================= + Create-Assignment Modal — zwei Intent-Spalten (Required + Available) + mit je drei Optionen (Neue Gruppe / All Users / All Devices). + Farbliche Unterscheidung via cg-intent-required/-available. + ============================================================= */ +.cg-intent-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 6px; +} +@media (max-width: 600px) { .cg-intent-grid { grid-template-columns: 1fr; } } + +.cg-intent-col { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--hairline); + border-radius: var(--r-md); + background: var(--surface); +} +/* Intent-Faerbung: Required = pink-ton, Available = accent-blau */ +.cg-intent-required { border-top: 3px solid var(--pink); } +.cg-intent-available { border-top: 3px solid var(--brand-accent); } + +.cg-intent-head { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 2px 4px; +} +.cg-intent-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 3px 8px; + border-radius: var(--r-xs); + align-self: flex-start; +} +.cg-intent-badge.req { background: var(--pink-soft); color: var(--pink); } +.cg-intent-badge.avail { background: var(--accent-soft); color: var(--brand-accent); } +.cg-intent-sub { + font-size: 11px; + color: var(--muted); +} + +.cg-mode { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--hairline-soft); + border-radius: var(--r-md); + background: var(--canvas); + cursor: pointer; + transition: all var(--tx); +} +.cg-mode:hover { background: var(--surface-soft); border-color: var(--surface-strong); } +.cg-mode input[type="radio"] { + margin-top: 2px; + flex-shrink: 0; +} +.cg-intent-required .cg-mode input[type="radio"] { accent-color: var(--pink); } +.cg-intent-available .cg-mode input[type="radio"] { accent-color: var(--brand-accent); } +.cg-intent-required .cg-mode:has(input:checked) { + border-color: var(--pink); + background: var(--pink-soft); +} +.cg-intent-available .cg-mode:has(input:checked) { + border-color: var(--brand-accent); + background: var(--accent-soft); +} +.cg-mode-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.cg-mode-title { + font-size: 12.5px; + font-weight: 600; + color: var(--ink); +} +.cg-mode-desc { + font-size: 11.5px; + color: var(--muted); + line-height: 1.4; +} + +/* Disabled-Card: Option bereits zugewiesen, keine zweite Zuweisung moeglich. + pointer-events: none verhindert Klicks ohne JS-Logik dafuer schreiben zu + muessen. Strike-Through im Title macht visuell sofort klar dass die + Option nicht waehlbar ist. */ +.cg-mode--disabled { + opacity: 0.5; + pointer-events: none; + background: var(--surface-soft); + border-color: var(--hairline-soft) !important; +} +.cg-mode--disabled .cg-mode-title { + text-decoration: line-through; + color: var(--muted); +} +.cg-mode--disabled .cg-mode-title::after { + content: ' ✓ aktiv'; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--success); + margin-left: 4px; + text-decoration: none; + display: inline-block; +} +.cg-mode--disabled .cg-mode-desc { + font-style: italic; +} + +/* ============================================================= + App-Detail — Installations-Statistik (Erfolge/Fehler je User/Device) + ============================================================= */ +.install-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 6px; +} +@media (max-width: 900px) { .install-stats { grid-template-columns: 1fr; } } +.install-axis { + border: 1px solid var(--detail-border-soft, var(--hairline)); + border-radius: var(--r-md); + background: var(--detail-surface, var(--surface)); + padding: 10px 12px; +} +.install-axis-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.install-axis-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--detail-text-muted, var(--muted)); +} +.install-axis-rate { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--r-pill); +} +.install-axis-rate.good { background: var(--success-soft); color: var(--success); } +.install-axis-rate.mid { background: var(--warning-soft); color: var(--warning); } +.install-axis-rate.bad { background: var(--error-soft); color: var(--error); } +.install-stats-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} +.stat-cell { + text-align: center; + padding: 8px 4px; + border-radius: var(--r-sm); + background: var(--canvas); + border: 1px solid var(--detail-border-soft, var(--hairline-soft)); +} +.stat-num { + font-size: 18px; + font-weight: 700; + line-height: 1.1; + font-variant-numeric: tabular-nums; + color: var(--detail-text, var(--ink)); +} +.stat-lbl { + font-size: 10px; + color: var(--detail-text-faint, var(--muted)); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 2px; +} +.stat-ok .stat-num { color: var(--success); } +.stat-err .stat-num { color: var(--error); } +.stat-pend .stat-num { color: var(--warning); } +.stat-not .stat-num { color: var(--muted); } +.stat-na .stat-num { color: var(--muted-soft); } + +/* ============================================================= + App-Detail — Flow-Visualisierung (Dependencies + Supersedence) + ============================================================= */ +.flow-subtitle { + font-size: 12px; + color: var(--detail-text-muted, var(--muted)); + margin: -2px 0 8px; +} +.flow-container { + width: 100%; + overflow-x: auto; + padding: 4px 0; +} +.flow-svg { + display: block; + color: var(--detail-text-faint, var(--muted)); + min-width: 640px; +} +.flow-arrow { + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.7; +} +.flow-arrow-label { + font-size: 10.5px; + font-family: var(--font-mono); + fill: var(--detail-text-muted, var(--muted)); + pointer-events: none; + font-weight: 600; +} +/* Label-Hintergrund — damit der Text nicht in die Linie reinlaeuft */ +.flow-arrow-label-bg { + fill: var(--detail-surface, var(--canvas)); + stroke: var(--detail-border-soft, var(--hairline-soft)); + stroke-width: 1; +} +.flow-node-wrap { + width: 100%; + height: 100%; + display: block; +} +.flow-node { + width: 100%; + height: 100%; + border: 1.5px solid var(--detail-border, var(--hairline)); + border-radius: var(--r-md); + background: var(--canvas); + padding: 8px 12px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + overflow: hidden; + box-sizing: border-box; + transition: all var(--tx); +} +.flow-node-name { + font-size: 12.5px; + font-weight: 600; + color: var(--detail-text, var(--ink)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.flow-node-meta { + font-size: 10.5px; + color: var(--detail-text-faint, var(--muted)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.flow-node-center { + border-color: var(--brand-accent); + background: var(--accent-soft); +} +.flow-node-center .flow-node-name { color: var(--brand-accent); } +.flow-node-known { + cursor: pointer; +} +.flow-node-known:hover { + border-color: var(--brand-accent); + box-shadow: 0 2px 8px -2px var(--accent-soft); +} +.flow-node-known:focus-visible { + outline: 2px solid var(--brand-accent); + outline-offset: 1px; +} + +/* Klartext-Erklaerungen unter dem Flow-Diagramm — beschreibt was die + Visualisierung in der UI tatsaechlich bedeutet (in welcher Reihenfolge + Intune installiert, was bei Supersedence passiert, etc.). */ +.flow-explanations { + margin-top: 14px; + padding: 12px 16px 14px; + background: var(--detail-surface, var(--surface-soft)); + border: 1px solid var(--detail-border-soft, var(--hairline-soft)); + border-radius: var(--r-md); + border-left: 3px solid var(--brand-accent); +} +.flow-explanations-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--detail-text-muted, var(--muted)); + margin-bottom: 6px; +} +.flow-explanations ul { + margin: 0; + padding: 0; + list-style: none; +} +.flow-explanations li { + padding: 4px 0; + font-size: 13px; + color: var(--detail-text, var(--ink)); + line-height: 1.55; +} +.flow-explanations li + li { + border-top: 1px dashed var(--detail-border-soft, var(--hairline-soft)); + margin-top: 4px; + padding-top: 8px; +} +.flow-explanations strong { + font-weight: 600; + color: var(--detail-text, var(--ink)); +} + +/* Flash-Animation wenn man via Flow-Klick zu einer App springt */ +.app-row-wrap--flash { + animation: rowFlash 1.2s ease-out; +} +@keyframes rowFlash { + 0%, 100% { box-shadow: none; } + 30% { box-shadow: 0 0 0 3px var(--brand-accent); } +} + +/* ============================================================= + Markdown-Content (App-Beschreibung). Wird in renderMarkdown + per marked.js zu HTML. Styling so, dass es sich in das + App-Detail-Panel einfuegt — keine grossen Headings, alles dezent. + ============================================================= */ +.md-content { line-height: 1.55; } +.md-content > *:first-child { margin-top: 0; } +.md-content > *:last-child { margin-bottom: 0; } +.md-content p { margin: 0 0 8px 0; } +.md-content h1, .md-content h2, .md-content h3, +.md-content h4, .md-content h5, .md-content h6 { + margin: 14px 0 6px; + font-weight: 700; + line-height: 1.25; + color: var(--detail-text, var(--ink)); + letter-spacing: -0.005em; +} +.md-content h1 { font-size: 15px; } +.md-content h2 { font-size: 14px; } +.md-content h3 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--detail-text-muted, var(--muted)); } +.md-content h4 { font-size: 12.5px; } +.md-content h5, .md-content h6 { font-size: 12px; color: var(--detail-text-muted, var(--muted)); } +.md-content strong { font-weight: 700; } +.md-content em { font-style: italic; } +.md-content del { text-decoration: line-through; color: var(--detail-text-muted, var(--muted)); } +.md-content a { + color: var(--brand-accent); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color var(--tx); +} +.md-content a:hover { border-bottom-color: var(--brand-accent); } +.md-content ul, .md-content ol { + margin: 0 0 8px 0; + padding-left: 22px; +} +.md-content li { padding: 1px 0; } +.md-content li > p { margin: 0; } +.md-content blockquote { + margin: 0 0 8px 0; + padding: 4px 12px; + border-left: 3px solid var(--detail-border, var(--hairline)); + color: var(--detail-text-muted, var(--muted)); + font-style: italic; + background: var(--detail-surface, var(--surface-soft)); + border-radius: 0 var(--r-xs) var(--r-xs) 0; +} +.md-content code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + padding: 1px 5px; + border-radius: var(--r-xs); + background: var(--detail-surface, var(--surface-soft)); + border: 1px solid var(--detail-border-soft, var(--hairline-soft)); + color: var(--detail-text, var(--ink)); + word-break: break-all; +} +.md-content pre { + margin: 0 0 8px 0; + padding: 10px 12px; + border-radius: var(--r-sm); + background: var(--detail-surface, var(--surface-soft)); + border: 1px solid var(--detail-border-soft, var(--hairline-soft)); + overflow-x: auto; + line-height: 1.45; +} +.md-content pre code { + background: transparent; + border: none; + padding: 0; + font-size: 11.5px; + white-space: pre; + word-break: normal; +} +.md-content table { + width: 100%; + border-collapse: collapse; + margin: 0 0 8px 0; + font-size: 12px; +} +.md-content th, .md-content td { + border: 1px solid var(--detail-border-soft, var(--hairline-soft)); + padding: 5px 8px; + text-align: left; + vertical-align: top; +} +.md-content th { + background: var(--detail-surface, var(--surface-soft)); + font-weight: 700; + color: var(--detail-text, var(--ink)); + font-size: 11.5px; +} +.md-content tr:nth-child(even) td { + background: var(--detail-surface, transparent); +} +.md-content hr { + border: none; + border-top: 1px solid var(--detail-border-soft, var(--hairline-soft)); + margin: 12px 0; +} +.md-content img { + max-width: 100%; + height: auto; + border-radius: var(--r-xs); +} + +/* ============================================================= + flatpickr (Date-Picker Popup) — Dark-Mode-Theming + Light-Mode nutzt den Default des Pakets, im Dark-Mode passen wir + die wichtigsten Tokens an unsere Surface-/Ink-Vars an. + ============================================================= */ +html[data-theme="dark"] .flatpickr-calendar { + background: var(--surface-soft); + color: var(--ink); + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + border: 1px solid var(--hairline); +} +html[data-theme="dark"] .flatpickr-calendar.arrowTop:before, +html[data-theme="dark"] .flatpickr-calendar.arrowBottom:before { + border-bottom-color: var(--hairline); + border-top-color: var(--hairline); +} +html[data-theme="dark"] .flatpickr-calendar.arrowTop:after, +html[data-theme="dark"] .flatpickr-calendar.arrowBottom:after { + border-bottom-color: var(--surface-soft); + border-top-color: var(--surface-soft); +} +html[data-theme="dark"] .flatpickr-months, +html[data-theme="dark"] .flatpickr-month { + color: var(--ink); + fill: var(--ink); + background: transparent; +} +html[data-theme="dark"] .flatpickr-current-month input.cur-year, +html[data-theme="dark"] .flatpickr-current-month .cur-month { + color: var(--ink); +} +html[data-theme="dark"] .flatpickr-monthDropdown-months { + background: var(--surface-soft); + color: var(--ink); +} +html[data-theme="dark"] .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month { + background: var(--surface-soft); +} +html[data-theme="dark"] .flatpickr-weekdays, +html[data-theme="dark"] span.flatpickr-weekday { + color: var(--muted); + background: transparent; +} +html[data-theme="dark"] .flatpickr-day { + color: var(--body); +} +html[data-theme="dark"] .flatpickr-day:hover, +html[data-theme="dark"] .flatpickr-day:focus { + background: var(--surface-strong); + border-color: var(--surface-strong); + color: var(--ink); +} +html[data-theme="dark"] .flatpickr-day.today { + border-color: var(--brand-accent); + color: var(--brand-accent); +} +html[data-theme="dark"] .flatpickr-day.today:hover { + background: var(--brand-accent); + color: var(--on-primary); +} +html[data-theme="dark"] .flatpickr-day.selected, +html[data-theme="dark"] .flatpickr-day.startRange, +html[data-theme="dark"] .flatpickr-day.endRange { + background: var(--brand-accent); + border-color: var(--brand-accent); + color: var(--on-primary); +} +html[data-theme="dark"] .flatpickr-day.flatpickr-disabled, +html[data-theme="dark"] .flatpickr-day.prevMonthDay, +html[data-theme="dark"] .flatpickr-day.nextMonthDay { + color: var(--muted-soft); +} +html[data-theme="dark"] .flatpickr-prev-month, +html[data-theme="dark"] .flatpickr-next-month { + color: var(--ink); + fill: var(--ink); +} +html[data-theme="dark"] .flatpickr-prev-month:hover svg, +html[data-theme="dark"] .flatpickr-next-month:hover svg { + fill: var(--brand-accent); +} +html[data-theme="dark"] .numInputWrapper:hover, +html[data-theme="dark"] .numInputWrapper span.arrowUp, +html[data-theme="dark"] .numInputWrapper span.arrowDown { + background: transparent; + border-color: var(--hairline); +} + +/* Kompakte Optik fuer beide Themes — passt besser zum Tool */ +.flatpickr-calendar { + font-family: inherit; + font-size: 12.5px; +} +.flatpickr-day { + font-size: 12px; +} diff --git a/Intune/Intune-App-Manager-Web/www/vendor/flatpickr.min.css b/Intune/Intune-App-Manager-Web/www/vendor/flatpickr.min.css new file mode 100644 index 0000000..a10acc6 --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/vendor/flatpickr.min.css @@ -0,0 +1,13 @@ +.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/* + /*rtl:begin:ignore*/left:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/* + /*rtl:begin:ignore*/right:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}} \ No newline at end of file diff --git a/Intune/Intune-App-Manager-Web/www/vendor/flatpickr.min.js b/Intune/Intune-App-Manager-Web/www/vendor/flatpickr.min.js new file mode 100644 index 0000000..b0f59ec --- /dev/null +++ b/Intune/Intune-App-Manager-Web/www/vendor/flatpickr.min.js @@ -0,0 +1,2 @@ +/* flatpickr v4.6.13,, @license MIT */ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).flatpickr=n()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var n,t=1,a=arguments.length;t",noCalendar:!1,now:new Date,onChange:[],onClose:[],onDayCreate:[],onDestroy:[],onKeyDown:[],onMonthChange:[],onOpen:[],onParseConfig:[],onReady:[],onValueUpdate:[],onYearChange:[],onPreCalendarPosition:[],plugins:[],position:"auto",positionElement:void 0,prevArrow:"",shorthandCurrentMonth:!1,showMonths:1,static:!1,time_24hr:!1,weekNumbers:!1,wrap:!1},i={weekdays:{shorthand:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],longhand:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},months:{shorthand:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],longhand:["January","February","March","April","May","June","July","August","September","October","November","December"]},daysInMonth:[31,28,31,30,31,30,31,31,30,31,30,31],firstDayOfWeek:0,ordinal:function(e){var n=e%100;if(n>3&&n<21)return"th";switch(n%10){case 1:return"st";case 2:return"nd";case 3:return"rd";default:return"th"}},rangeSeparator:" to ",weekAbbreviation:"Wk",scrollTitle:"Scroll to increment",toggleTitle:"Click to toggle",amPM:["AM","PM"],yearAriaLabel:"Year",monthAriaLabel:"Month",hourAriaLabel:"Hour",minuteAriaLabel:"Minute",time_24hr:!1},o=function(e,n){return void 0===n&&(n=2),("000"+e).slice(-1*n)},r=function(e){return!0===e?1:0};function l(e,n){var t;return function(){var a=this,i=arguments;clearTimeout(t),t=setTimeout((function(){return e.apply(a,i)}),n)}}var c=function(e){return e instanceof Array?e:[e]};function s(e,n,t){if(!0===t)return e.classList.add(n);e.classList.remove(n)}function d(e,n,t){var a=window.document.createElement(e);return n=n||"",t=t||"",a.className=n,void 0!==t&&(a.textContent=t),a}function u(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function f(e,n){return n(e)?e:e.parentNode?f(e.parentNode,n):void 0}function m(e,n){var t=d("div","numInputWrapper"),a=d("input","numInput "+e),i=d("span","arrowUp"),o=d("span","arrowDown");if(-1===navigator.userAgent.indexOf("MSIE 9.0")?a.type="number":(a.type="text",a.pattern="\\d*"),void 0!==n)for(var r in n)a.setAttribute(r,n[r]);return t.appendChild(a),t.appendChild(i),t.appendChild(o),t}function g(e){try{return"function"==typeof e.composedPath?e.composedPath()[0]:e.target}catch(n){return e.target}}var p=function(){},h=function(e,n,t){return t.months[n?"shorthand":"longhand"][e]},v={D:p,F:function(e,n,t){e.setMonth(t.months.longhand.indexOf(n))},G:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},H:function(e,n){e.setHours(parseFloat(n))},J:function(e,n){e.setDate(parseFloat(n))},K:function(e,n,t){e.setHours(e.getHours()%12+12*r(new RegExp(t.amPM[1],"i").test(n)))},M:function(e,n,t){e.setMonth(t.months.shorthand.indexOf(n))},S:function(e,n){e.setSeconds(parseFloat(n))},U:function(e,n){return new Date(1e3*parseFloat(n))},W:function(e,n,t){var a=parseInt(n),i=new Date(e.getFullYear(),0,2+7*(a-1),0,0,0,0);return i.setDate(i.getDate()-i.getDay()+t.firstDayOfWeek),i},Y:function(e,n){e.setFullYear(parseFloat(n))},Z:function(e,n){return new Date(n)},d:function(e,n){e.setDate(parseFloat(n))},h:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},i:function(e,n){e.setMinutes(parseFloat(n))},j:function(e,n){e.setDate(parseFloat(n))},l:p,m:function(e,n){e.setMonth(parseFloat(n)-1)},n:function(e,n){e.setMonth(parseFloat(n)-1)},s:function(e,n){e.setSeconds(parseFloat(n))},u:function(e,n){return new Date(parseFloat(n))},w:p,y:function(e,n){e.setFullYear(2e3+parseFloat(n))}},D={D:"",F:"",G:"(\\d\\d|\\d)",H:"(\\d\\d|\\d)",J:"(\\d\\d|\\d)\\w+",K:"",M:"",S:"(\\d\\d|\\d)",U:"(.+)",W:"(\\d\\d|\\d)",Y:"(\\d{4})",Z:"(.+)",d:"(\\d\\d|\\d)",h:"(\\d\\d|\\d)",i:"(\\d\\d|\\d)",j:"(\\d\\d|\\d)",l:"",m:"(\\d\\d|\\d)",n:"(\\d\\d|\\d)",s:"(\\d\\d|\\d)",u:"(.+)",w:"(\\d\\d|\\d)",y:"(\\d{2})"},w={Z:function(e){return e.toISOString()},D:function(e,n,t){return n.weekdays.shorthand[w.w(e,n,t)]},F:function(e,n,t){return h(w.n(e,n,t)-1,!1,n)},G:function(e,n,t){return o(w.h(e,n,t))},H:function(e){return o(e.getHours())},J:function(e,n){return void 0!==n.ordinal?e.getDate()+n.ordinal(e.getDate()):e.getDate()},K:function(e,n){return n.amPM[r(e.getHours()>11)]},M:function(e,n){return h(e.getMonth(),!0,n)},S:function(e){return o(e.getSeconds())},U:function(e){return e.getTime()/1e3},W:function(e,n,t){return t.getWeek(e)},Y:function(e){return o(e.getFullYear(),4)},d:function(e){return o(e.getDate())},h:function(e){return e.getHours()%12?e.getHours()%12:12},i:function(e){return o(e.getMinutes())},j:function(e){return e.getDate()},l:function(e,n){return n.weekdays.longhand[e.getDay()]},m:function(e){return o(e.getMonth()+1)},n:function(e){return e.getMonth()+1},s:function(e){return e.getSeconds()},u:function(e){return e.getTime()},w:function(e){return e.getDay()},y:function(e){return String(e.getFullYear()).substring(2)}},b=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o,l=e.isMobile,c=void 0!==l&&l;return function(e,n,a){var i=a||r;return void 0===t.formatDate||c?n.split("").map((function(n,a,o){return w[n]&&"\\"!==o[a-1]?w[n](e,i,t):"\\"!==n?n:""})).join(""):t.formatDate(e,n,i)}},C=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o;return function(e,n,i,o){if(0===e||e){var l,c=o||r,s=e;if(e instanceof Date)l=new Date(e.getTime());else if("string"!=typeof e&&void 0!==e.toFixed)l=new Date(e);else if("string"==typeof e){var d=n||(t||a).dateFormat,u=String(e).trim();if("today"===u)l=new Date,i=!0;else if(t&&t.parseDate)l=t.parseDate(e,d);else if(/Z$/.test(u)||/GMT$/.test(u))l=new Date(e);else{for(var f=void 0,m=[],g=0,p=0,h="";g=0?new Date:new Date(w.config.minDate.getTime()),t=E(w.config);n.setHours(t.hours,t.minutes,t.seconds,n.getMilliseconds()),w.selectedDates=[n],w.latestSelectedDateObj=n}void 0!==e&&"blur"!==e.type&&function(e){e.preventDefault();var n="keydown"===e.type,t=g(e),a=t;void 0!==w.amPM&&t===w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]);var i=parseFloat(a.getAttribute("min")),l=parseFloat(a.getAttribute("max")),c=parseFloat(a.getAttribute("step")),s=parseInt(a.value,10),d=e.delta||(n?38===e.which?1:-1:0),u=s+c*d;if(void 0!==a.value&&2===a.value.length){var f=a===w.hourElement,m=a===w.minuteElement;ul&&(u=a===w.hourElement?u-l-r(!w.amPM):i,m&&L(void 0,1,w.hourElement)),w.amPM&&f&&(1===c?u+s===23:Math.abs(u-s)>c)&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]),a.value=o(u)}}(e);var a=w._input.value;O(),ye(),w._input.value!==a&&w._debouncedChange()}function O(){if(void 0!==w.hourElement&&void 0!==w.minuteElement){var e,n,t=(parseInt(w.hourElement.value.slice(-2),10)||0)%24,a=(parseInt(w.minuteElement.value,10)||0)%60,i=void 0!==w.secondElement?(parseInt(w.secondElement.value,10)||0)%60:0;void 0!==w.amPM&&(e=t,n=w.amPM.textContent,t=e%12+12*r(n===w.l10n.amPM[1]));var o=void 0!==w.config.minTime||w.config.minDate&&w.minDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.minDate,!0),l=void 0!==w.config.maxTime||w.config.maxDate&&w.maxDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.maxDate,!0);if(void 0!==w.config.maxTime&&void 0!==w.config.minTime&&w.config.minTime>w.config.maxTime){var c=y(w.config.minTime.getHours(),w.config.minTime.getMinutes(),w.config.minTime.getSeconds()),s=y(w.config.maxTime.getHours(),w.config.maxTime.getMinutes(),w.config.maxTime.getSeconds()),d=y(t,a,i);if(d>s&&d=12)]),void 0!==w.secondElement&&(w.secondElement.value=o(t)))}function N(e){var n=g(e),t=parseInt(n.value)+(e.delta||0);(t/1e3>1||"Enter"===e.key&&!/[^\d]/.test(t.toString()))&&ee(t)}function P(e,n,t,a){return n instanceof Array?n.forEach((function(n){return P(e,n,t,a)})):e instanceof Array?e.forEach((function(e){return P(e,n,t,a)})):(e.addEventListener(n,t,a),void w._handlers.push({remove:function(){return e.removeEventListener(n,t,a)}}))}function Y(){De("onChange")}function j(e,n){var t=void 0!==e?w.parseDate(e):w.latestSelectedDateObj||(w.config.minDate&&w.config.minDate>w.now?w.config.minDate:w.config.maxDate&&w.config.maxDate=0&&M(e,w.selectedDates[1])<=0)}(n)&&!be(n)&&o.classList.add("inRange"),w.weekNumbers&&1===w.config.showMonths&&"prevMonthDay"!==e&&a%7==6&&w.weekNumbers.insertAdjacentHTML("beforeend",""+w.config.getWeek(n)+""),De("onDayCreate",o),o}function W(e){e.focus(),"range"===w.config.mode&&oe(e)}function B(e){for(var n=e>0?0:w.config.showMonths-1,t=e>0?w.config.showMonths:-1,a=n;a!=t;a+=e)for(var i=w.daysContainer.children[a],o=e>0?0:i.children.length-1,r=e>0?i.children.length:-1,l=o;l!=r;l+=e){var c=i.children[l];if(-1===c.className.indexOf("hidden")&&ne(c.dateObj))return c}}function J(e,n){var t=k(),a=te(t||document.body),i=void 0!==e?e:a?t:void 0!==w.selectedDateElem&&te(w.selectedDateElem)?w.selectedDateElem:void 0!==w.todayDateElem&&te(w.todayDateElem)?w.todayDateElem:B(n>0?1:-1);void 0===i?w._input.focus():a?function(e,n){for(var t=-1===e.className.indexOf("Month")?e.dateObj.getMonth():w.currentMonth,a=n>0?w.config.showMonths:-1,i=n>0?1:-1,o=t-w.currentMonth;o!=a;o+=i)for(var r=w.daysContainer.children[o],l=t-w.currentMonth===o?e.$i+n:n<0?r.children.length-1:0,c=r.children.length,s=l;s>=0&&s0?c:-1);s+=i){var d=r.children[s];if(-1===d.className.indexOf("hidden")&&ne(d.dateObj)&&Math.abs(e.$i-s)>=Math.abs(n))return W(d)}w.changeMonth(i),J(B(i),0)}(i,n):W(i)}function K(e,n){for(var t=(new Date(e,n,1).getDay()-w.l10n.firstDayOfWeek+7)%7,a=w.utils.getDaysInMonth((n-1+12)%12,e),i=w.utils.getDaysInMonth(n,e),o=window.document.createDocumentFragment(),r=w.config.showMonths>1,l=r?"prevMonthDay hidden":"prevMonthDay",c=r?"nextMonthDay hidden":"nextMonthDay",s=a+1-t,u=0;s<=a;s++,u++)o.appendChild(R("flatpickr-day "+l,new Date(e,n-1,s),0,u));for(s=1;s<=i;s++,u++)o.appendChild(R("flatpickr-day",new Date(e,n,s),0,u));for(var f=i+1;f<=42-t&&(1===w.config.showMonths||u%7!=0);f++,u++)o.appendChild(R("flatpickr-day "+c,new Date(e,n+1,f%i),0,u));var m=d("div","dayContainer");return m.appendChild(o),m}function U(){if(void 0!==w.daysContainer){u(w.daysContainer),w.weekNumbers&&u(w.weekNumbers);for(var e=document.createDocumentFragment(),n=0;n1||"dropdown"!==w.config.monthSelectorType)){var e=function(e){return!(void 0!==w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&ew.config.maxDate.getMonth())};w.monthsDropdownContainer.tabIndex=-1,w.monthsDropdownContainer.innerHTML="";for(var n=0;n<12;n++)if(e(n)){var t=d("option","flatpickr-monthDropdown-month");t.value=new Date(w.currentYear,n).getMonth().toString(),t.textContent=h(n,w.config.shorthandCurrentMonth,w.l10n),t.tabIndex=-1,w.currentMonth===n&&(t.selected=!0),w.monthsDropdownContainer.appendChild(t)}}}function $(){var e,n=d("div","flatpickr-month"),t=window.document.createDocumentFragment();w.config.showMonths>1||"static"===w.config.monthSelectorType?e=d("span","cur-month"):(w.monthsDropdownContainer=d("select","flatpickr-monthDropdown-months"),w.monthsDropdownContainer.setAttribute("aria-label",w.l10n.monthAriaLabel),P(w.monthsDropdownContainer,"change",(function(e){var n=g(e),t=parseInt(n.value,10);w.changeMonth(t-w.currentMonth),De("onMonthChange")})),q(),e=w.monthsDropdownContainer);var a=m("cur-year",{tabindex:"-1"}),i=a.getElementsByTagName("input")[0];i.setAttribute("aria-label",w.l10n.yearAriaLabel),w.config.minDate&&i.setAttribute("min",w.config.minDate.getFullYear().toString()),w.config.maxDate&&(i.setAttribute("max",w.config.maxDate.getFullYear().toString()),i.disabled=!!w.config.minDate&&w.config.minDate.getFullYear()===w.config.maxDate.getFullYear());var o=d("div","flatpickr-current-month");return o.appendChild(e),o.appendChild(a),t.appendChild(o),n.appendChild(t),{container:n,yearElement:i,monthElement:e}}function V(){u(w.monthNav),w.monthNav.appendChild(w.prevMonthNav),w.config.showMonths&&(w.yearElements=[],w.monthElements=[]);for(var e=w.config.showMonths;e--;){var n=$();w.yearElements.push(n.yearElement),w.monthElements.push(n.monthElement),w.monthNav.appendChild(n.container)}w.monthNav.appendChild(w.nextMonthNav)}function z(){w.weekdayContainer?u(w.weekdayContainer):w.weekdayContainer=d("div","flatpickr-weekdays");for(var e=w.config.showMonths;e--;){var n=d("div","flatpickr-weekdaycontainer");w.weekdayContainer.appendChild(n)}return G(),w.weekdayContainer}function G(){if(w.weekdayContainer){var e=w.l10n.firstDayOfWeek,t=n(w.l10n.weekdays.shorthand);e>0&&e\n "+t.join("")+"\n \n "}}function Z(e,n){void 0===n&&(n=!0);var t=n?e:e-w.currentMonth;t<0&&!0===w._hidePrevMonthArrow||t>0&&!0===w._hideNextMonthArrow||(w.currentMonth+=t,(w.currentMonth<0||w.currentMonth>11)&&(w.currentYear+=w.currentMonth>11?1:-1,w.currentMonth=(w.currentMonth+12)%12,De("onYearChange"),q()),U(),De("onMonthChange"),Ce())}function Q(e){return w.calendarContainer.contains(e)}function X(e){if(w.isOpen&&!w.config.inline){var n=g(e),t=Q(n),a=!(n===w.input||n===w.altInput||w.element.contains(n)||e.path&&e.path.indexOf&&(~e.path.indexOf(w.input)||~e.path.indexOf(w.altInput)))&&!t&&!Q(e.relatedTarget),i=!w.config.ignoredFocusElements.some((function(e){return e.contains(n)}));a&&i&&(w.config.allowInput&&w.setDate(w._input.value,!1,w.config.altInput?w.config.altFormat:w.config.dateFormat),void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement&&""!==w.input.value&&void 0!==w.input.value&&_(),w.close(),w.config&&"range"===w.config.mode&&1===w.selectedDates.length&&w.clear(!1))}}function ee(e){if(!(!e||w.config.minDate&&ew.config.maxDate.getFullYear())){var n=e,t=w.currentYear!==n;w.currentYear=n||w.currentYear,w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth=Math.min(w.config.maxDate.getMonth(),w.currentMonth):w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&(w.currentMonth=Math.max(w.config.minDate.getMonth(),w.currentMonth)),t&&(w.redraw(),De("onYearChange"),q())}}function ne(e,n){var t;void 0===n&&(n=!0);var a=w.parseDate(e,void 0,n);if(w.config.minDate&&a&&M(a,w.config.minDate,void 0!==n?n:!w.minDateHasTime)<0||w.config.maxDate&&a&&M(a,w.config.maxDate,void 0!==n?n:!w.maxDateHasTime)>0)return!1;if(!w.config.enable&&0===w.config.disable.length)return!0;if(void 0===a)return!1;for(var i=!!w.config.enable,o=null!==(t=w.config.enable)&&void 0!==t?t:w.config.disable,r=0,l=void 0;r=l.from.getTime()&&a.getTime()<=l.to.getTime())return i}return!i}function te(e){return void 0!==w.daysContainer&&(-1===e.className.indexOf("hidden")&&-1===e.className.indexOf("flatpickr-disabled")&&w.daysContainer.contains(e))}function ae(e){var n=e.target===w._input,t=w._input.value.trimEnd()!==Me();!n||!t||e.relatedTarget&&Q(e.relatedTarget)||w.setDate(w._input.value,!0,e.target===w.altInput?w.config.altFormat:w.config.dateFormat)}function ie(e){var n=g(e),t=w.config.wrap?p.contains(n):n===w._input,a=w.config.allowInput,i=w.isOpen&&(!a||!t),o=w.config.inline&&t&&!a;if(13===e.keyCode&&t){if(a)return w.setDate(w._input.value,!0,n===w.altInput?w.config.altFormat:w.config.dateFormat),w.close(),n.blur();w.open()}else if(Q(n)||i||o){var r=!!w.timeContainer&&w.timeContainer.contains(n);switch(e.keyCode){case 13:r?(e.preventDefault(),_(),fe()):me(e);break;case 27:e.preventDefault(),fe();break;case 8:case 46:t&&!w.config.allowInput&&(e.preventDefault(),w.clear());break;case 37:case 39:if(r||t)w.hourElement&&w.hourElement.focus();else{e.preventDefault();var l=k();if(void 0!==w.daysContainer&&(!1===a||l&&te(l))){var c=39===e.keyCode?1:-1;e.ctrlKey?(e.stopPropagation(),Z(c),J(B(1),0)):J(void 0,c)}}break;case 38:case 40:e.preventDefault();var s=40===e.keyCode?1:-1;w.daysContainer&&void 0!==n.$i||n===w.input||n===w.altInput?e.ctrlKey?(e.stopPropagation(),ee(w.currentYear-s),J(B(1),0)):r||J(void 0,7*s):n===w.currentYearElement?ee(w.currentYear-s):w.config.enableTime&&(!r&&w.hourElement&&w.hourElement.focus(),_(e),w._debouncedChange());break;case 9:if(r){var d=[w.hourElement,w.minuteElement,w.secondElement,w.amPM].concat(w.pluginElements).filter((function(e){return e})),u=d.indexOf(n);if(-1!==u){var f=d[u+(e.shiftKey?-1:1)];e.preventDefault(),(f||w._input).focus()}}else!w.config.noCalendar&&w.daysContainer&&w.daysContainer.contains(n)&&e.shiftKey&&(e.preventDefault(),w._input.focus())}}if(void 0!==w.amPM&&n===w.amPM)switch(e.key){case w.l10n.amPM[0].charAt(0):case w.l10n.amPM[0].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[0],O(),ye();break;case w.l10n.amPM[1].charAt(0):case w.l10n.amPM[1].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[1],O(),ye()}(t||Q(n))&&De("onKeyDown",e)}function oe(e,n){if(void 0===n&&(n="flatpickr-day"),1===w.selectedDates.length&&(!e||e.classList.contains(n)&&!e.classList.contains("flatpickr-disabled"))){for(var t=e?e.dateObj.getTime():w.days.firstElementChild.dateObj.getTime(),a=w.parseDate(w.selectedDates[0],void 0,!0).getTime(),i=Math.min(t,w.selectedDates[0].getTime()),o=Math.max(t,w.selectedDates[0].getTime()),r=!1,l=0,c=0,s=i;si&&sl)?l=s:s>a&&(!c||s ."+n)).forEach((function(n){var i,o,s,d=n.dateObj.getTime(),u=l>0&&d0&&d>c;if(u)return n.classList.add("notAllowed"),void["inRange","startRange","endRange"].forEach((function(e){n.classList.remove(e)}));r&&!u||(["startRange","inRange","endRange","notAllowed"].forEach((function(e){n.classList.remove(e)})),void 0!==e&&(e.classList.add(t<=w.selectedDates[0].getTime()?"startRange":"endRange"),at&&d===a&&n.classList.add("endRange"),d>=l&&(0===c||d<=c)&&(o=a,s=t,(i=d)>Math.min(o,s)&&i0||t.getMinutes()>0||t.getSeconds()>0),w.selectedDates&&(w.selectedDates=w.selectedDates.filter((function(e){return ne(e)})),w.selectedDates.length||"min"!==e||F(t),ye()),w.daysContainer&&(ue(),void 0!==t?w.currentYearElement[e]=t.getFullYear().toString():w.currentYearElement.removeAttribute(e),w.currentYearElement.disabled=!!a&&void 0!==t&&a.getFullYear()===t.getFullYear())}}function ce(){return w.config.wrap?p.querySelector("[data-input]"):p}function se(){"object"!=typeof w.config.locale&&void 0===I.l10ns[w.config.locale]&&w.config.errorHandler(new Error("flatpickr: invalid locale "+w.config.locale)),w.l10n=e(e({},I.l10ns.default),"object"==typeof w.config.locale?w.config.locale:"default"!==w.config.locale?I.l10ns[w.config.locale]:void 0),D.D="("+w.l10n.weekdays.shorthand.join("|")+")",D.l="("+w.l10n.weekdays.longhand.join("|")+")",D.M="("+w.l10n.months.shorthand.join("|")+")",D.F="("+w.l10n.months.longhand.join("|")+")",D.K="("+w.l10n.amPM[0]+"|"+w.l10n.amPM[1]+"|"+w.l10n.amPM[0].toLowerCase()+"|"+w.l10n.amPM[1].toLowerCase()+")",void 0===e(e({},v),JSON.parse(JSON.stringify(p.dataset||{}))).time_24hr&&void 0===I.defaultConfig.time_24hr&&(w.config.time_24hr=w.l10n.time_24hr),w.formatDate=b(w),w.parseDate=C({config:w.config,l10n:w.l10n})}function de(e){if("function"!=typeof w.config.position){if(void 0!==w.calendarContainer){De("onPreCalendarPosition");var n=e||w._positionElement,t=Array.prototype.reduce.call(w.calendarContainer.children,(function(e,n){return e+n.offsetHeight}),0),a=w.calendarContainer.offsetWidth,i=w.config.position.split(" "),o=i[0],r=i.length>1?i[1]:null,l=n.getBoundingClientRect(),c=window.innerHeight-l.bottom,d="above"===o||"below"!==o&&ct,u=window.pageYOffset+l.top+(d?-t-2:n.offsetHeight+2);if(s(w.calendarContainer,"arrowTop",!d),s(w.calendarContainer,"arrowBottom",d),!w.config.inline){var f=window.pageXOffset+l.left,m=!1,g=!1;"center"===r?(f-=(a-l.width)/2,m=!0):"right"===r&&(f-=a-l.width,g=!0),s(w.calendarContainer,"arrowLeft",!m&&!g),s(w.calendarContainer,"arrowCenter",m),s(w.calendarContainer,"arrowRight",g);var p=window.document.body.offsetWidth-(window.pageXOffset+l.right),h=f+a>window.document.body.offsetWidth,v=p+a>window.document.body.offsetWidth;if(s(w.calendarContainer,"rightMost",h),!w.config.static)if(w.calendarContainer.style.top=u+"px",h)if(v){var D=function(){for(var e=null,n=0;nw.currentMonth+w.config.showMonths-1)&&"range"!==w.config.mode;if(w.selectedDateElem=t,"single"===w.config.mode)w.selectedDates=[a];else if("multiple"===w.config.mode){var o=be(a);o?w.selectedDates.splice(parseInt(o),1):w.selectedDates.push(a)}else"range"===w.config.mode&&(2===w.selectedDates.length&&w.clear(!1,!1),w.latestSelectedDateObj=a,w.selectedDates.push(a),0!==M(a,w.selectedDates[0],!0)&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()})));if(O(),i){var r=w.currentYear!==a.getFullYear();w.currentYear=a.getFullYear(),w.currentMonth=a.getMonth(),r&&(De("onYearChange"),q()),De("onMonthChange")}if(Ce(),U(),ye(),i||"range"===w.config.mode||1!==w.config.showMonths?void 0!==w.selectedDateElem&&void 0===w.hourElement&&w.selectedDateElem&&w.selectedDateElem.focus():W(t),void 0!==w.hourElement&&void 0!==w.hourElement&&w.hourElement.focus(),w.config.closeOnSelect){var l="single"===w.config.mode&&!w.config.enableTime,c="range"===w.config.mode&&2===w.selectedDates.length&&!w.config.enableTime;(l||c)&&fe()}Y()}}w.parseDate=C({config:w.config,l10n:w.l10n}),w._handlers=[],w.pluginElements=[],w.loadedPlugins=[],w._bind=P,w._setHoursFromDate=F,w._positionCalendar=de,w.changeMonth=Z,w.changeYear=ee,w.clear=function(e,n){void 0===e&&(e=!0);void 0===n&&(n=!0);w.input.value="",void 0!==w.altInput&&(w.altInput.value="");void 0!==w.mobileInput&&(w.mobileInput.value="");w.selectedDates=[],w.latestSelectedDateObj=void 0,!0===n&&(w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth());if(!0===w.config.enableTime){var t=E(w.config),a=t.hours,i=t.minutes,o=t.seconds;A(a,i,o)}w.redraw(),e&&De("onChange")},w.close=function(){w.isOpen=!1,w.isMobile||(void 0!==w.calendarContainer&&w.calendarContainer.classList.remove("open"),void 0!==w._input&&w._input.classList.remove("active"));De("onClose")},w.onMouseOver=oe,w._createElement=d,w.createDay=R,w.destroy=function(){void 0!==w.config&&De("onDestroy");for(var e=w._handlers.length;e--;)w._handlers[e].remove();if(w._handlers=[],w.mobileInput)w.mobileInput.parentNode&&w.mobileInput.parentNode.removeChild(w.mobileInput),w.mobileInput=void 0;else if(w.calendarContainer&&w.calendarContainer.parentNode)if(w.config.static&&w.calendarContainer.parentNode){var n=w.calendarContainer.parentNode;if(n.lastChild&&n.removeChild(n.lastChild),n.parentNode){for(;n.firstChild;)n.parentNode.insertBefore(n.firstChild,n);n.parentNode.removeChild(n)}}else w.calendarContainer.parentNode.removeChild(w.calendarContainer);w.altInput&&(w.input.type="text",w.altInput.parentNode&&w.altInput.parentNode.removeChild(w.altInput),delete w.altInput);w.input&&(w.input.type=w.input._type,w.input.classList.remove("flatpickr-input"),w.input.removeAttribute("readonly"));["_showTimeInput","latestSelectedDateObj","_hideNextMonthArrow","_hidePrevMonthArrow","__hideNextMonthArrow","__hidePrevMonthArrow","isMobile","isOpen","selectedDateElem","minDateHasTime","maxDateHasTime","days","daysContainer","_input","_positionElement","innerContainer","rContainer","monthNav","todayDateElem","calendarContainer","weekdayContainer","prevMonthNav","nextMonthNav","monthsDropdownContainer","currentMonthElement","currentYearElement","navigationCurrentMonth","selectedDateElem","config"].forEach((function(e){try{delete w[e]}catch(e){}}))},w.isEnabled=ne,w.jumpToDate=j,w.updateValue=ye,w.open=function(e,n){void 0===n&&(n=w._positionElement);if(!0===w.isMobile){if(e){e.preventDefault();var t=g(e);t&&t.blur()}return void 0!==w.mobileInput&&(w.mobileInput.focus(),w.mobileInput.click()),void De("onOpen")}if(w._input.disabled||w.config.inline)return;var a=w.isOpen;w.isOpen=!0,a||(w.calendarContainer.classList.add("open"),w._input.classList.add("active"),De("onOpen"),de(n));!0===w.config.enableTime&&!0===w.config.noCalendar&&(!1!==w.config.allowInput||void 0!==e&&w.timeContainer.contains(e.relatedTarget)||setTimeout((function(){return w.hourElement.select()}),50))},w.redraw=ue,w.set=function(e,n){if(null!==e&&"object"==typeof e)for(var a in Object.assign(w.config,e),e)void 0!==ge[a]&&ge[a].forEach((function(e){return e()}));else w.config[e]=n,void 0!==ge[e]?ge[e].forEach((function(e){return e()})):t.indexOf(e)>-1&&(w.config[e]=c(n));w.redraw(),ye(!0)},w.setDate=function(e,n,t){void 0===n&&(n=!1);void 0===t&&(t=w.config.dateFormat);if(0!==e&&!e||e instanceof Array&&0===e.length)return w.clear(n);pe(e,t),w.latestSelectedDateObj=w.selectedDates[w.selectedDates.length-1],w.redraw(),j(void 0,n),F(),0===w.selectedDates.length&&w.clear(!1);ye(n),n&&De("onChange")},w.toggle=function(e){if(!0===w.isOpen)return w.close();w.open(e)};var ge={locale:[se,G],showMonths:[V,S,z],minDate:[j],maxDate:[j],positionElement:[ve],clickOpens:[function(){!0===w.config.clickOpens?(P(w._input,"focus",w.open),P(w._input,"click",w.open)):(w._input.removeEventListener("focus",w.open),w._input.removeEventListener("click",w.open))}]};function pe(e,n){var t=[];if(e instanceof Array)t=e.map((function(e){return w.parseDate(e,n)}));else if(e instanceof Date||"number"==typeof e)t=[w.parseDate(e,n)];else if("string"==typeof e)switch(w.config.mode){case"single":case"time":t=[w.parseDate(e,n)];break;case"multiple":t=e.split(w.config.conjunction).map((function(e){return w.parseDate(e,n)}));break;case"range":t=e.split(w.l10n.rangeSeparator).map((function(e){return w.parseDate(e,n)}))}else w.config.errorHandler(new Error("Invalid date supplied: "+JSON.stringify(e)));w.selectedDates=w.config.allowInvalidPreload?t:t.filter((function(e){return e instanceof Date&&ne(e,!1)})),"range"===w.config.mode&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()}))}function he(e){return e.slice().map((function(e){return"string"==typeof e||"number"==typeof e||e instanceof Date?w.parseDate(e,void 0,!0):e&&"object"==typeof e&&e.from&&e.to?{from:w.parseDate(e.from,void 0),to:w.parseDate(e.to,void 0)}:e})).filter((function(e){return e}))}function ve(){w._positionElement=w.config.positionElement||w._input}function De(e,n){if(void 0!==w.config){var t=w.config[e];if(void 0!==t&&t.length>0)for(var a=0;t[a]&&a1||"static"===w.config.monthSelectorType?w.monthElements[n].textContent=h(t.getMonth(),w.config.shorthandCurrentMonth,w.l10n)+" ":w.monthsDropdownContainer.value=t.getMonth().toString(),e.value=t.getFullYear().toString()})),w._hidePrevMonthArrow=void 0!==w.config.minDate&&(w.currentYear===w.config.minDate.getFullYear()?w.currentMonth<=w.config.minDate.getMonth():w.currentYearw.config.maxDate.getMonth():w.currentYear>w.config.maxDate.getFullYear()))}function Me(e){var n=e||(w.config.altInput?w.config.altFormat:w.config.dateFormat);return w.selectedDates.map((function(e){return w.formatDate(e,n)})).filter((function(e,n,t){return"range"!==w.config.mode||w.config.enableTime||t.indexOf(e)===n})).join("range"!==w.config.mode?w.config.conjunction:w.l10n.rangeSeparator)}function ye(e){void 0===e&&(e=!0),void 0!==w.mobileInput&&w.mobileFormatStr&&(w.mobileInput.value=void 0!==w.latestSelectedDateObj?w.formatDate(w.latestSelectedDateObj,w.mobileFormatStr):""),w.input.value=Me(w.config.dateFormat),void 0!==w.altInput&&(w.altInput.value=Me(w.config.altFormat)),!1!==e&&De("onValueUpdate")}function xe(e){var n=g(e),t=w.prevMonthNav.contains(n),a=w.nextMonthNav.contains(n);t||a?Z(t?-1:1):w.yearElements.indexOf(n)>=0?n.select():n.classList.contains("arrowUp")?w.changeYear(w.currentYear+1):n.classList.contains("arrowDown")&&w.changeYear(w.currentYear-1)}return function(){w.element=w.input=p,w.isOpen=!1,function(){var n=["wrap","weekNumbers","allowInput","allowInvalidPreload","clickOpens","time_24hr","enableTime","noCalendar","altInput","shorthandCurrentMonth","inline","static","enableSeconds","disableMobile"],i=e(e({},JSON.parse(JSON.stringify(p.dataset||{}))),v),o={};w.config.parseDate=i.parseDate,w.config.formatDate=i.formatDate,Object.defineProperty(w.config,"enable",{get:function(){return w.config._enable},set:function(e){w.config._enable=he(e)}}),Object.defineProperty(w.config,"disable",{get:function(){return w.config._disable},set:function(e){w.config._disable=he(e)}});var r="time"===i.mode;if(!i.dateFormat&&(i.enableTime||r)){var l=I.defaultConfig.dateFormat||a.dateFormat;o.dateFormat=i.noCalendar||r?"H:i"+(i.enableSeconds?":S":""):l+" H:i"+(i.enableSeconds?":S":"")}if(i.altInput&&(i.enableTime||r)&&!i.altFormat){var s=I.defaultConfig.altFormat||a.altFormat;o.altFormat=i.noCalendar||r?"h:i"+(i.enableSeconds?":S K":" K"):s+" h:i"+(i.enableSeconds?":S":"")+" K"}Object.defineProperty(w.config,"minDate",{get:function(){return w.config._minDate},set:le("min")}),Object.defineProperty(w.config,"maxDate",{get:function(){return w.config._maxDate},set:le("max")});var d=function(e){return function(n){w.config["min"===e?"_minTime":"_maxTime"]=w.parseDate(n,"H:i:S")}};Object.defineProperty(w.config,"minTime",{get:function(){return w.config._minTime},set:d("min")}),Object.defineProperty(w.config,"maxTime",{get:function(){return w.config._maxTime},set:d("max")}),"time"===i.mode&&(w.config.noCalendar=!0,w.config.enableTime=!0);Object.assign(w.config,o,i);for(var u=0;u-1?w.config[m]=c(f[m]).map(T).concat(w.config[m]):void 0===i[m]&&(w.config[m]=f[m])}i.altInputClass||(w.config.altInputClass=ce().className+" "+w.config.altInputClass);De("onParseConfig")}(),se(),function(){if(w.input=ce(),!w.input)return void w.config.errorHandler(new Error("Invalid input element specified"));w.input._type=w.input.type,w.input.type="text",w.input.classList.add("flatpickr-input"),w._input=w.input,w.config.altInput&&(w.altInput=d(w.input.nodeName,w.config.altInputClass),w._input=w.altInput,w.altInput.placeholder=w.input.placeholder,w.altInput.disabled=w.input.disabled,w.altInput.required=w.input.required,w.altInput.tabIndex=w.input.tabIndex,w.altInput.type="text",w.input.setAttribute("type","hidden"),!w.config.static&&w.input.parentNode&&w.input.parentNode.insertBefore(w.altInput,w.input.nextSibling));w.config.allowInput||w._input.setAttribute("readonly","readonly");ve()}(),function(){w.selectedDates=[],w.now=w.parseDate(w.config.now)||new Date;var e=w.config.defaultDate||("INPUT"!==w.input.nodeName&&"TEXTAREA"!==w.input.nodeName||!w.input.placeholder||w.input.value!==w.input.placeholder?w.input.value:null);e&&pe(e,w.config.dateFormat);w._initialDate=w.selectedDates.length>0?w.selectedDates[0]:w.config.minDate&&w.config.minDate.getTime()>w.now.getTime()?w.config.minDate:w.config.maxDate&&w.config.maxDate.getTime()0&&(w.latestSelectedDateObj=w.selectedDates[0]);void 0!==w.config.minTime&&(w.config.minTime=w.parseDate(w.config.minTime,"H:i"));void 0!==w.config.maxTime&&(w.config.maxTime=w.parseDate(w.config.maxTime,"H:i"));w.minDateHasTime=!!w.config.minDate&&(w.config.minDate.getHours()>0||w.config.minDate.getMinutes()>0||w.config.minDate.getSeconds()>0),w.maxDateHasTime=!!w.config.maxDate&&(w.config.maxDate.getHours()>0||w.config.maxDate.getMinutes()>0||w.config.maxDate.getSeconds()>0)}(),w.utils={getDaysInMonth:function(e,n){return void 0===e&&(e=w.currentMonth),void 0===n&&(n=w.currentYear),1===e&&(n%4==0&&n%100!=0||n%400==0)?29:w.l10n.daysInMonth[e]}},w.isMobile||function(){var e=window.document.createDocumentFragment();if(w.calendarContainer=d("div","flatpickr-calendar"),w.calendarContainer.tabIndex=-1,!w.config.noCalendar){if(e.appendChild((w.monthNav=d("div","flatpickr-months"),w.yearElements=[],w.monthElements=[],w.prevMonthNav=d("span","flatpickr-prev-month"),w.prevMonthNav.innerHTML=w.config.prevArrow,w.nextMonthNav=d("span","flatpickr-next-month"),w.nextMonthNav.innerHTML=w.config.nextArrow,V(),Object.defineProperty(w,"_hidePrevMonthArrow",{get:function(){return w.__hidePrevMonthArrow},set:function(e){w.__hidePrevMonthArrow!==e&&(s(w.prevMonthNav,"flatpickr-disabled",e),w.__hidePrevMonthArrow=e)}}),Object.defineProperty(w,"_hideNextMonthArrow",{get:function(){return w.__hideNextMonthArrow},set:function(e){w.__hideNextMonthArrow!==e&&(s(w.nextMonthNav,"flatpickr-disabled",e),w.__hideNextMonthArrow=e)}}),w.currentYearElement=w.yearElements[0],Ce(),w.monthNav)),w.innerContainer=d("div","flatpickr-innerContainer"),w.config.weekNumbers){var n=function(){w.calendarContainer.classList.add("hasWeeks");var e=d("div","flatpickr-weekwrapper");e.appendChild(d("span","flatpickr-weekday",w.l10n.weekAbbreviation));var n=d("div","flatpickr-weeks");return e.appendChild(n),{weekWrapper:e,weekNumbers:n}}(),t=n.weekWrapper,a=n.weekNumbers;w.innerContainer.appendChild(t),w.weekNumbers=a,w.weekWrapper=t}w.rContainer=d("div","flatpickr-rContainer"),w.rContainer.appendChild(z()),w.daysContainer||(w.daysContainer=d("div","flatpickr-days"),w.daysContainer.tabIndex=-1),U(),w.rContainer.appendChild(w.daysContainer),w.innerContainer.appendChild(w.rContainer),e.appendChild(w.innerContainer)}w.config.enableTime&&e.appendChild(function(){w.calendarContainer.classList.add("hasTime"),w.config.noCalendar&&w.calendarContainer.classList.add("noCalendar");var e=E(w.config);w.timeContainer=d("div","flatpickr-time"),w.timeContainer.tabIndex=-1;var n=d("span","flatpickr-time-separator",":"),t=m("flatpickr-hour",{"aria-label":w.l10n.hourAriaLabel});w.hourElement=t.getElementsByTagName("input")[0];var a=m("flatpickr-minute",{"aria-label":w.l10n.minuteAriaLabel});w.minuteElement=a.getElementsByTagName("input")[0],w.hourElement.tabIndex=w.minuteElement.tabIndex=-1,w.hourElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getHours():w.config.time_24hr?e.hours:function(e){switch(e%24){case 0:case 12:return 12;default:return e%12}}(e.hours)),w.minuteElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getMinutes():e.minutes),w.hourElement.setAttribute("step",w.config.hourIncrement.toString()),w.minuteElement.setAttribute("step",w.config.minuteIncrement.toString()),w.hourElement.setAttribute("min",w.config.time_24hr?"0":"1"),w.hourElement.setAttribute("max",w.config.time_24hr?"23":"12"),w.hourElement.setAttribute("maxlength","2"),w.minuteElement.setAttribute("min","0"),w.minuteElement.setAttribute("max","59"),w.minuteElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(t),w.timeContainer.appendChild(n),w.timeContainer.appendChild(a),w.config.time_24hr&&w.timeContainer.classList.add("time24hr");if(w.config.enableSeconds){w.timeContainer.classList.add("hasSeconds");var i=m("flatpickr-second");w.secondElement=i.getElementsByTagName("input")[0],w.secondElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getSeconds():e.seconds),w.secondElement.setAttribute("step",w.minuteElement.getAttribute("step")),w.secondElement.setAttribute("min","0"),w.secondElement.setAttribute("max","59"),w.secondElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(d("span","flatpickr-time-separator",":")),w.timeContainer.appendChild(i)}w.config.time_24hr||(w.amPM=d("span","flatpickr-am-pm",w.l10n.amPM[r((w.latestSelectedDateObj?w.hourElement.value:w.config.defaultHour)>11)]),w.amPM.title=w.l10n.toggleTitle,w.amPM.tabIndex=-1,w.timeContainer.appendChild(w.amPM));return w.timeContainer}());s(w.calendarContainer,"rangeMode","range"===w.config.mode),s(w.calendarContainer,"animate",!0===w.config.animate),s(w.calendarContainer,"multiMonth",w.config.showMonths>1),w.calendarContainer.appendChild(e);var i=void 0!==w.config.appendTo&&void 0!==w.config.appendTo.nodeType;if((w.config.inline||w.config.static)&&(w.calendarContainer.classList.add(w.config.inline?"inline":"static"),w.config.inline&&(!i&&w.element.parentNode?w.element.parentNode.insertBefore(w.calendarContainer,w._input.nextSibling):void 0!==w.config.appendTo&&w.config.appendTo.appendChild(w.calendarContainer)),w.config.static)){var l=d("div","flatpickr-wrapper");w.element.parentNode&&w.element.parentNode.insertBefore(l,w.element),l.appendChild(w.element),w.altInput&&l.appendChild(w.altInput),l.appendChild(w.calendarContainer)}w.config.static||w.config.inline||(void 0!==w.config.appendTo?w.config.appendTo:window.document.body).appendChild(w.calendarContainer)}(),function(){w.config.wrap&&["open","close","toggle","clear"].forEach((function(e){Array.prototype.forEach.call(w.element.querySelectorAll("[data-"+e+"]"),(function(n){return P(n,"click",w[e])}))}));if(w.isMobile)return void function(){var e=w.config.enableTime?w.config.noCalendar?"time":"datetime-local":"date";w.mobileInput=d("input",w.input.className+" flatpickr-mobile"),w.mobileInput.tabIndex=1,w.mobileInput.type=e,w.mobileInput.disabled=w.input.disabled,w.mobileInput.required=w.input.required,w.mobileInput.placeholder=w.input.placeholder,w.mobileFormatStr="datetime-local"===e?"Y-m-d\\TH:i:S":"date"===e?"Y-m-d":"H:i:S",w.selectedDates.length>0&&(w.mobileInput.defaultValue=w.mobileInput.value=w.formatDate(w.selectedDates[0],w.mobileFormatStr));w.config.minDate&&(w.mobileInput.min=w.formatDate(w.config.minDate,"Y-m-d"));w.config.maxDate&&(w.mobileInput.max=w.formatDate(w.config.maxDate,"Y-m-d"));w.input.getAttribute("step")&&(w.mobileInput.step=String(w.input.getAttribute("step")));w.input.type="hidden",void 0!==w.altInput&&(w.altInput.type="hidden");try{w.input.parentNode&&w.input.parentNode.insertBefore(w.mobileInput,w.input.nextSibling)}catch(e){}P(w.mobileInput,"change",(function(e){w.setDate(g(e).value,!1,w.mobileFormatStr),De("onChange"),De("onClose")}))}();var e=l(re,50);w._debouncedChange=l(Y,300),w.daysContainer&&!/iPhone|iPad|iPod/i.test(navigator.userAgent)&&P(w.daysContainer,"mouseover",(function(e){"range"===w.config.mode&&oe(g(e))}));P(w._input,"keydown",ie),void 0!==w.calendarContainer&&P(w.calendarContainer,"keydown",ie);w.config.inline||w.config.static||P(window,"resize",e);void 0!==window.ontouchstart?P(window.document,"touchstart",X):P(window.document,"mousedown",X);P(window.document,"focus",X,{capture:!0}),!0===w.config.clickOpens&&(P(w._input,"focus",w.open),P(w._input,"click",w.open));void 0!==w.daysContainer&&(P(w.monthNav,"click",xe),P(w.monthNav,["keyup","increment"],N),P(w.daysContainer,"click",me));if(void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement){var n=function(e){return g(e).select()};P(w.timeContainer,["increment"],_),P(w.timeContainer,"blur",_,{capture:!0}),P(w.timeContainer,"click",H),P([w.hourElement,w.minuteElement],["focus","click"],n),void 0!==w.secondElement&&P(w.secondElement,"focus",(function(){return w.secondElement&&w.secondElement.select()})),void 0!==w.amPM&&P(w.amPM,"click",(function(e){_(e)}))}w.config.allowInput&&P(w._input,"blur",ae)}(),(w.selectedDates.length||w.config.noCalendar)&&(w.config.enableTime&&F(w.config.noCalendar?w.latestSelectedDateObj:void 0),ye(!1)),S();var n=/^((?!chrome|android).)*safari/i.test(navigator.userAgent);!w.isMobile&&n&&de(),De("onReady")}(),w}function T(e,n){for(var t=Array.prototype.slice.call(e).filter((function(e){return e instanceof HTMLElement})),a=[],i=0;i{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(` +`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,` +`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],` +`).split(` +`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(` +`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+` +`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=` +`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=` +`+g}!T&&!g.trim()&&(T=!0),p+=G+` +`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=` +`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+` +`;return s?'
    '+(n?i:R(i,!0))+`
    +`:"
    "+(n?i:R(i,!0))+`
    +`}blockquote({tokens:e}){return`
    +${this.parser.parse(e)}
    +`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
    +`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let i=0;i${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${R(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='
    ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`${n}{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+R(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports}));