diff --git a/config/app_access.php b/config/app_access.php new file mode 100644 index 00000000..0dae23de --- /dev/null +++ b/config/app_access.php @@ -0,0 +1,5 @@ +/` - `Mining-Checker` als erstes angebundenes klassisches Modul - `Waehrungs-Checker` als zweites angebundenes klassisches Modul mit Desktop-Widget fuer Kursaktualisierung - versionierte API auf demselben Host unter `(staging.)desktop.kusche.berlin/api/v1/...` -- Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix +- Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Tray-Apps, Widgets und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix - vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende - LDAP-Synchronisierung fuer Standardfelder wie Name, E-Mail, Telefon, Titel und Ort - `Geburtsdatum` aktuell bewusst nur lokal gespeichert @@ -146,7 +150,7 @@ Fuer einen spaeteren Hilfebereich sollen Inhalte aus dieser Datei in Themenblcke - `Desktop bedienen` - `Menue verstehen` - `Fenster verwenden` -- `Infobereich konfigurieren` +- `Infobereich und Widgets konfigurieren` - `Apps und Desktop Type verwalten` - `Module verstehen und starten` - `API-Pfade und Versionen verstehen` diff --git a/docs/README.md b/docs/README.md index 699677a0..49fe971b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,10 +60,11 @@ Aktuell wichtig: - der Boersenchecker ist als importierte Desktop-App unter `custom/apps/boersenchecker/` angebunden - Modul-Assets koennen ueber einen gemeinsamen Auslieferungsweg aus dem Modul selbst geladen werden - die API bleibt vorerst auf demselben Host und wird nicht auf `api.desktop.kusche.berlin` ausgelagert -- das gemeinsame Admin-Debug-Widget neben der Uhr ist der Standard fuer Live-Debugging in Desktop und Native-Apps +- die gemeinsame Admin-Debug-Tray-Funktion neben der Uhr ist der Standard fuer Live-Debugging in Desktop und Native-Apps - der Waehrungs-Checker stellt zusaetzlich ein Desktop-Widget fuer manuelle Kursaktualisierung bereit - der Gitea-Deploy-Status liegt als optionales Addon unter `system/addons/gitea-deploy-status/` und wird serverseitig ueber `config/gitea.php` angebunden - `Admin Apps` ist als globales `Systemtool` fuer App-Bestand, Widgets und erste Integrations-Einstellungen vorhanden +- LDAP-Gruppenberechtigungen fuer Apps werden zentral ueber die Desktop-App-Verwaltung gepflegt - das Oeffnen des Waehrungs-Checkers darf keinen externen Kursabruf ausloesen; Refreshes laufen nur nach Altersregel oder mit `force` - `Systemtools` und installierbare `Module` werden getrennt behandelt; Systemtools sind nicht Teil der Benutzer-Installationsauswahl - das `Cron Tool` ist als globales Systemtool vorhanden und sammelt Modul-Cronjobs automatisch ueber Manifest-Metadaten diff --git a/docs/WEITERENTWICKLUNG.md b/docs/WEITERENTWICKLUNG.md index 37c15a81..fb08a2a9 100644 --- a/docs/WEITERENTWICKLUNG.md +++ b/docs/WEITERENTWICKLUNG.md @@ -29,7 +29,7 @@ Verbindlich ist: - globale Debug-Infrastruktur zentral halten und ueber alle Apps wiederverwenden - Begriffe in UI und Doku konsistent nach `CONTENT.md` verwenden - Apps als bereitgestellte Systemfunktionen denken, nicht nur als sichtbare Fenster -- Unterschiede zwischen `App`, `Infobereich` und `Widget-Bereich` sauber trennen +- Unterschiede zwischen `App`, `Tray-App`, `Widget`, `Infobereich` und `Tray-Bereich` sauber trennen - lokale Persistenz und spaetere Backend-Synchronisierung getrennt vorbereiten - Nutzerdaten und Desktop-Preferences bevorzugt datenbankbasiert speichern, nicht dateibasiert - `README.md`-Dateien in Teilbereichen aktuell halten @@ -38,6 +38,7 @@ Verbindlich ist: - importierte Module mit eigener API ueber konsistente `public/api//index.php`-Shims an die gemeinsame Desktop-Auth anbinden - importierte Module mit eigener Fensterseite ueber konsistente `public/apps//index.php`-Shims anbinden - Widget-Logik einer App muss dieselben fachlichen Regeln wie die App-API verwenden, nicht eigene Sonderwege +- App-Berechtigungen fuer LDAP-Gruppen muessen zentral und pro App nachvollziehbar pflegbar bleiben - Systemtools duerfen nicht als installierbare Module modelliert werden - neue Modul-Widgets und Modul-Cronjobs sollen ueber Manifest-Metadaten registriert werden statt ueber versteckte Sonderlisten @@ -66,6 +67,8 @@ Verbindlich ist: - die Definition globaler Debug- und Shell-Werkzeuge soll in `system/shell/` liegen und nicht lose in `public/` oder verstreut im JS - API und Desktop teilen sich im aktuellen Zielbild denselben Host; offizielle Basis ist `/api/v1/...` - App, API und Widget einer Fachfunktion muessen dieselben Sperr- und Force-Regeln fuer externe Abrufe nutzen +- Nutzersteuerung fuer Menue-App, Tray-App, Desktop-Icon und Widget muss getrennt speicherbar bleiben +- Widgets sollen fachlich als Desktop-Elemente gedacht werden; die aktuelle Einblendung im rechten Infobereich ist nur ein Uebergang und darf spaeter sauber ersetzt werden - die Trennung `core` / `system_tool` / `module` ist in Metadaten, Startmenue und Benutzer-Setup konsistent zu halten - Cron-Endpunkte muessen auf demselben Host laufen und duerfen keine separate Standard-API-Domain voraussetzen - Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index 2f23daf6..b716bf8f 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -1913,7 +1913,7 @@ if (payloadNode) { groups.forEach((group) => { const apps = payload.apps.filter((app) => { - if (app.show_in_start_menu === false) { + if (app.show_in_start_menu === false || app.user_show_in_start_menu === false) { return false; } @@ -2000,7 +2000,7 @@ if (payloadNode) { }; payload.apps - .filter((app) => app.show_on_desktop !== false) + .filter((app) => app.user_show_on_desktop === true) .forEach((app) => { const icon = document.createElement('button'); icon.type = 'button'; diff --git a/src/App/AdminAppsService.php b/src/App/AdminAppsService.php index a40baa21..68611b45 100644 --- a/src/App/AdminAppsService.php +++ b/src/App/AdminAppsService.php @@ -24,7 +24,8 @@ final class AdminAppsService require_once AppPaths::systemAddonPath($this->projectRoot, 'gitea-deploy-status') . '/GiteaDeployStatusService.php'; $moduleRegistry = new ModuleRegistry(AppPaths::customAppsRoot($this->projectRoot)); - $appRegistry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); + $accessOverrides = AppAccessConfig::load($this->projectRoot); + $appRegistry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps(), $accessOverrides); $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php', $moduleRegistry->widgets()); $apps = $this->normalizeApps($appRegistry->all()); @@ -37,6 +38,8 @@ final class AdminAppsService 'meta' => [ 'environment' => $currentEnvironment, 'gitea_config_path' => $this->environmentConfigPath('gitea', $currentEnvironment), + 'app_access_config_path' => $this->environmentConfigPath('app_access', $currentEnvironment), + 'available_groups' => AppAccessConfig::availableGroups($this->projectRoot), ], 'stats' => [ 'apps_total' => count($apps), @@ -85,6 +88,15 @@ final class AdminAppsService return $config; } + /** + * @param array $requiredGroups + * @return array}> + */ + public function saveAppAccess(string $appId, array $requiredGroups): array + { + return AppAccessConfig::saveRequiredGroups($this->projectRoot, $appId, $requiredGroups); + } + /** * @param array> $apps * @return array> @@ -107,6 +119,7 @@ final class AdminAppsService 'supports_tray' => !empty($app['supports_tray']), 'enabled_by_default' => !empty($app['enabled_by_default']), 'required_groups' => $requiredGroups, + 'required_groups_display' => $requiredGroups === [] ? 'Alle sichtbaren Benutzer' : implode(', ', $requiredGroups), 'admin_only' => in_array('administrators', array_map( static fn (string $group): string => strtolower(trim($group, '/')), $requiredGroups diff --git a/src/App/App.php b/src/App/App.php index ac5e3d5b..4bb4d5e7 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -31,7 +31,11 @@ final class App $keycloakConfig = ConfigLoader::load($this->projectRoot, 'keycloak'); $auth = new KeycloakAuth($keycloakConfig); $moduleRegistry = new ModuleRegistry(AppPaths::customAppsRoot($this->projectRoot)); - $registry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); + $registry = new AppRegistry( + $this->projectRoot . '/config/apps.php', + $moduleRegistry->desktopApps(), + AppAccessConfig::load($this->projectRoot) + ); $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php', $moduleRegistry->widgets()); $shellRegistry = new ShellAppRegistry(AppPaths::systemShellRoot($this->projectRoot)); $giteaStatus = new \SystemAddons\GiteaDeployStatus\GiteaDeployStatusService(ConfigLoader::load($this->projectRoot, 'gitea')); @@ -40,7 +44,10 @@ final class App $authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; - $authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? []))); + $authGroups = array_values(array_map( + static fn (string $group): string => strtolower(trim($group, '/')), + array_map('strval', (array) ($authUser['groups'] ?? [])) + )); $isAdmin = in_array('administrators', $authGroups, true); $visibleApps = AppVisibility::filterByGroups($registry->all(), $authGroups); $userSelfManagement = new UserSelfManagementService($this->projectRoot); @@ -53,22 +60,47 @@ final class App ); $userSettings = $resolvedSkin['settings']; $activeSkin = (string) $resolvedSkin['skin']; - $enabledApps = AppVisibility::filterByEnabledIds( + $menuAppIds = array_values(array_map('strval', (array) ($userSettings['apps']['enabled_ids'] ?? []))); + $desktopIconIds = array_values(array_map('strval', (array) ($userSettings['apps']['desktop_icon_ids'] ?? []))); + $trayAppIds = array_values(array_map('strval', (array) ($userSettings['tray']['active_ids'] ?? []))); + $visibleAppIds = array_values(array_filter(array_map( + static fn (array $app): string => (string) ($app['app_id'] ?? ''), + $visibleApps + ))); + $availableWidgets = $widgetRegistry->availableForApps($visibleAppIds); + $activeWidgetIds = array_values(array_map('strval', (array) ($userSettings['widgets']['active_ids'] ?? DesktopState::defaultWidgetIds()))); + $activeWidgets = $widgetRegistry->active($activeWidgetIds, $visibleAppIds); + $widgetLaunchAppIds = array_values(array_filter(array_unique(array_map( + static fn (array $widget): string => (string) (($widget['launch_app_id'] ?? '') ?: ($widget['source_app_id'] ?? '')), + $activeWidgets + )))); + $payloadAppIds = array_values(array_unique(array_filter(array_merge( + $menuAppIds, + $desktopIconIds, + $trayAppIds, + $widgetLaunchAppIds, + [UserSelfManagementService::APP_ID] + )))); + $payloadApps = AppVisibility::filterByEnabledIds( $visibleApps, - array_values(array_map('strval', (array) ($userSettings['apps']['enabled_ids'] ?? []))), + $payloadAppIds, [UserSelfManagementService::APP_ID] ); - $apps = AppIconResolver::decorate($enabledApps, $activeSkin); + $apps = AppIconResolver::decorate(array_map(static function (array $app) use ($menuAppIds, $desktopIconIds, $trayAppIds): array { + $appId = (string) ($app['app_id'] ?? ''); + $installable = !array_key_exists('installable', $app) || (bool) $app['installable']; + $app['user_show_in_start_menu'] = $installable + ? in_array($appId, $menuAppIds, true) + : (($app['show_in_start_menu'] ?? true) !== false); + $app['user_show_on_desktop'] = in_array($appId, $desktopIconIds, true); + $app['user_show_in_tray'] = in_array($appId, $trayAppIds, true); + + return $app; + }, $payloadApps), $activeSkin); $windows = (new WindowManager($registry))->defaultWindows(); - $enabledAppIds = array_values(array_filter(array_map( - static fn (array $app): string => (string) ($app['app_id'] ?? ''), - $enabledApps - ))); - $availableWidgets = $widgetRegistry->availableForApps($enabledAppIds); - $activeWidgetIds = array_values(array_map('strval', (array) ($userSettings['widgets']['active_ids'] ?? DesktopState::defaultWidgetIds()))); $trayItems = DesktopState::trayItems(); - foreach ($enabledApps as $app) { - if (empty($app['supports_tray']) || (string) ($app['app_id'] ?? '') !== 'fx-rates') { + foreach ($apps as $app) { + if (empty($app['supports_tray']) || empty($app['user_show_in_tray'])) { continue; } @@ -120,7 +152,7 @@ final class App 'apps' => $apps, 'windows' => $windows, 'widgets' => [ - 'active' => $widgetRegistry->active($activeWidgetIds, $enabledAppIds), + 'active' => $activeWidgets, 'registry' => $availableWidgets, 'active_ids' => $activeWidgetIds, ], diff --git a/src/App/AppAccessConfig.php b/src/App/AppAccessConfig.php new file mode 100644 index 00000000..02771a57 --- /dev/null +++ b/src/App/AppAccessConfig.php @@ -0,0 +1,161 @@ +}> + */ + public static function load(string $projectRoot): array + { + $config = ConfigLoader::load($projectRoot, 'app_access'); + $normalized = []; + + foreach ($config as $appId => $definition) { + $appId = trim((string) $appId); + if ($appId === '' || !is_array($definition)) { + continue; + } + + $normalized[$appId] = [ + 'required_groups' => self::sanitizeGroups($definition['required_groups'] ?? []), + ]; + } + + return $normalized; + } + + /** + * @return array + */ + public static function availableGroups(string $projectRoot): array + { + $registration = ConfigLoader::load($projectRoot, 'registration'); + $groups = []; + + foreach ((array) ($registration['ldap']['available_groups'] ?? []) as $entry) { + if (!is_array($entry)) { + continue; + } + + $dn = trim((string) ($entry['dn'] ?? '')); + $label = trim((string) ($entry['label'] ?? '')); + $id = self::ldapGroupId($dn); + + if ($id === '') { + continue; + } + + $groups[$id] = [ + 'id' => $id, + 'label' => $label !== '' ? $label : $id, + 'dn' => $dn, + ]; + } + + ksort($groups); + + return array_values($groups); + } + + /** + * @param array $requiredGroups + * @return array}> + */ + public static function saveRequiredGroups(string $projectRoot, string $appId, array $requiredGroups): array + { + $appId = trim($appId); + if ($appId === '') { + throw new \RuntimeException('App-ID fehlt.'); + } + + $current = self::load($projectRoot); + $current[$appId] = [ + 'required_groups' => self::sanitizeGroups($requiredGroups), + ]; + + self::writeEnvironmentConfig($projectRoot, 'app_access', ConfigLoader::currentEnvironment(), $current); + + return $current; + } + + private static function ldapGroupId(string $dn): string + { + if ($dn === '') { + return ''; + } + + if (preg_match('/cn=([^,]+)/i', $dn, $matches) !== 1) { + return ''; + } + + return strtolower(trim((string) ($matches[1] ?? ''))); + } + + /** + * @param mixed $groups + * @return array + */ + private static function sanitizeGroups(mixed $groups): array + { + $result = []; + + foreach ((array) $groups as $group) { + $group = strtolower(trim((string) $group, " /\t\n\r\0\x0B")); + if ($group === '') { + continue; + } + + $result[$group] = $group; + } + + return array_values($result); + } + + /** + * @param array $config + */ + private static function writeEnvironmentConfig(string $projectRoot, string $name, string $environment, array $config): void + { + $directory = rtrim($projectRoot, '/') . '/config/' . $environment; + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new \RuntimeException('Konfigurationsverzeichnis konnte nicht angelegt werden.'); + } + + $path = $directory . '/' . $name . '.php'; + $content = " $item) { + $prefix = is_int($key) ? '' : var_export($key, true) . ' => '; + $lines[] = $childIndent . $prefix . self::exportPhpValue($item, $depth + 1) . ','; + } + + $lines[] = $indent . ']'; + + return implode("\n", $lines); + } + + return var_export($value, true); + } +} diff --git a/src/App/UserSelfManagementService.php b/src/App/UserSelfManagementService.php index 4675c3c0..4e487bf8 100644 --- a/src/App/UserSelfManagementService.php +++ b/src/App/UserSelfManagementService.php @@ -105,21 +105,30 @@ final class UserSelfManagementService { $settings = $this->load($authUser, $visibleApps, $widgets, $availableSkins); $enabledAppIds = array_values(array_map('strval', (array) ($settings['apps']['enabled_ids'] ?? []))); - $enabledAppLookup = array_fill_keys($enabledAppIds, true); + $desktopIconIds = array_values(array_map('strval', (array) ($settings['apps']['desktop_icon_ids'] ?? []))); + $trayAppIds = array_values(array_map('strval', (array) ($settings['tray']['active_ids'] ?? []))); + $visibleAppLookup = array_fill_keys(array_values(array_filter(array_map( + static fn (array $app): string => (string) ($app['app_id'] ?? ''), + $visibleApps + ))), true); $activeWidgetIds = array_values(array_map('strval', (array) ($settings['widgets']['active_ids'] ?? []))); $installableApps = array_values(array_filter( $visibleApps, static fn (array $app): bool => !array_key_exists('installable', $app) || (bool) $app['installable'] )); + $trayApps = array_values(array_filter( + $visibleApps, + static fn (array $app): bool => !empty($app['supports_tray']) + )); $availableWidgets = array_values(array_filter( $widgets, - static function (array $widget) use ($enabledAppLookup): bool { + static function (array $widget) use ($visibleAppLookup): bool { $sourceAppId = trim((string) ($widget['source_app_id'] ?? '')); if ($sourceAppId === '') { return true; } - return isset($enabledAppLookup[$sourceAppId]); + return isset($visibleAppLookup[$sourceAppId]); } )); @@ -142,9 +151,20 @@ final class UserSelfManagementService 'app_scope' => (string) ($app['app_scope'] ?? 'module'), 'required' => in_array((string) ($app['app_id'] ?? ''), [self::APP_ID], true), 'enabled' => in_array((string) ($app['app_id'] ?? ''), $enabledAppIds, true), + 'can_show_on_desktop' => ($app['show_on_desktop'] ?? false) !== false, + 'desktop_icon_enabled' => in_array((string) ($app['app_id'] ?? ''), $desktopIconIds, true), ], $installableApps ), + 'tray_apps' => array_map( + static fn (array $app): array => [ + 'app_id' => (string) ($app['app_id'] ?? ''), + 'title' => (string) ($app['title'] ?? ''), + 'summary' => (string) ($app['summary'] ?? ''), + 'enabled' => in_array((string) ($app['app_id'] ?? ''), $trayAppIds, true), + ], + $trayApps + ), 'widgets' => array_map( static fn (array $widget): array => [ 'widget_id' => (string) ($widget['widget_id'] ?? ''), @@ -153,6 +173,7 @@ final class UserSelfManagementService 'default_enabled' => (bool) ($widget['default_enabled'] ?? false), 'enabled' => in_array((string) ($widget['widget_id'] ?? ''), $activeWidgetIds, true), 'source_app_id' => (string) ($widget['source_app_id'] ?? ''), + 'widget_type' => (string) ($widget['widget_type'] ?? 'default'), ], $availableWidgets ), @@ -220,12 +241,44 @@ final class UserSelfManagementService static fn (array $app): ?string => ($app['enabled_by_default'] ?? false) ? (string) ($app['app_id'] ?? '') : null, $visibleApps ))); + $desktopIconAllowedIds = array_values(array_filter(array_map( + static fn (array $app): ?string => ((!array_key_exists('installable', $app) || (bool) $app['installable']) && (($app['show_on_desktop'] ?? false) !== false)) + ? (string) ($app['app_id'] ?? '') + : null, + $visibleApps + ))); + $defaultDesktopIconIds = array_values(array_filter(array_map( + static fn (array $app): ?string => ((!array_key_exists('installable', $app) || (bool) $app['installable']) && !empty($app['show_on_desktop'])) + ? (string) ($app['app_id'] ?? '') + : null, + $visibleApps + ))); + $trayAllowedIds = array_values(array_filter(array_map( + static fn (array $app): ?string => !empty($app['supports_tray']) ? (string) ($app['app_id'] ?? '') : null, + $visibleApps + ))); + $defaultTrayIds = array_values(array_filter(array_map( + static fn (array $app): ?string => (!empty($app['supports_tray']) && !empty($app['enabled_by_default'])) ? (string) ($app['app_id'] ?? '') : null, + $visibleApps + ))); $defaultWidgetIds = array_values(array_filter(array_map( static fn (array $widget): ?string => ($widget['default_enabled'] ?? false) ? (string) ($widget['widget_id'] ?? '') : null, $widgets ))); - $allWidgetIds = array_values(array_filter(array_map( - static fn (array $widget): string => (string) ($widget['widget_id'] ?? ''), + $visibleWidgetIds = array_values(array_filter(array_map( + static function (array $widget) use ($visibleAppIds): ?string { + $widgetId = (string) ($widget['widget_id'] ?? ''); + $sourceAppId = trim((string) ($widget['source_app_id'] ?? '')); + if ($widgetId === '') { + return null; + } + + if ($sourceAppId !== '' && !in_array($sourceAppId, $visibleAppIds, true)) { + return null; + } + + return $widgetId; + }, $widgets ))); $storedEnabledAppIds = array_key_exists('enabled_ids', (array) ($settings['apps'] ?? [])) @@ -244,12 +297,34 @@ final class UserSelfManagementService $visibleAppIds, [self::APP_ID] ); + $storedDesktopIconIds = array_key_exists('desktop_icon_ids', (array) ($settings['apps'] ?? [])) + ? $this->sanitizeSelection($settings['apps']['desktop_icon_ids'] ?? [], $desktopIconAllowedIds) + : $defaultDesktopIconIds; + $desktopIconIds = $this->sanitizeSelection( + $storedDesktopIconIds, + array_values(array_intersect($desktopIconAllowedIds, $enabledAppIds)) + ); + $storedTrayIds = array_key_exists('active_ids', (array) ($settings['tray'] ?? [])) + ? $this->sanitizeSelection($settings['tray']['active_ids'] ?? [], $trayAllowedIds) + : $defaultTrayIds; + $knownTrayIds = $this->sanitizeSelection( + $settings['tray']['known_ids'] ?? [], + $trayAllowedIds + ); + $newDefaultTrayIds = array_values(array_filter( + $defaultTrayIds, + static fn (string $appId): bool => !in_array($appId, $knownTrayIds, true) + )); + $trayIds = $this->sanitizeSelection( + array_merge($storedTrayIds, $newDefaultTrayIds), + $trayAllowedIds + ); $storedActiveWidgetIds = array_key_exists('active_ids', (array) ($settings['widgets'] ?? [])) - ? $this->sanitizeSelection($settings['widgets']['active_ids'] ?? [], $allWidgetIds) + ? $this->sanitizeSelection($settings['widgets']['active_ids'] ?? [], $visibleWidgetIds) : $defaultWidgetIds; $knownWidgetIds = $this->sanitizeSelection( $settings['widgets']['known_ids'] ?? [], - $allWidgetIds + $visibleWidgetIds ); $newDefaultWidgetIds = array_values(array_filter( $defaultWidgetIds, @@ -257,14 +332,14 @@ final class UserSelfManagementService )); $activeWidgetIds = $this->sanitizeSelection( array_merge($storedActiveWidgetIds, $newDefaultWidgetIds), - array_values(array_filter($allWidgetIds, function (string $widgetId) use ($widgets, $enabledAppIds): bool { + array_values(array_filter($visibleWidgetIds, function (string $widgetId) use ($widgets, $visibleAppIds): bool { foreach ($widgets as $widget) { if ((string) ($widget['widget_id'] ?? '') !== $widgetId) { continue; } $sourceAppId = trim((string) ($widget['source_app_id'] ?? '')); - return $sourceAppId === '' || in_array($sourceAppId, $enabledAppIds, true); + return $sourceAppId === '' || in_array($sourceAppId, $visibleAppIds, true); } return false; @@ -297,11 +372,16 @@ final class UserSelfManagementService ], 'apps' => [ 'enabled_ids' => $enabledAppIds, + 'desktop_icon_ids' => $desktopIconIds, 'known_ids' => $visibleAppIds, ], + 'tray' => [ + 'active_ids' => $trayIds, + 'known_ids' => $trayAllowedIds, + ], 'widgets' => [ 'active_ids' => $activeWidgetIds, - 'known_ids' => $allWidgetIds, + 'known_ids' => $visibleWidgetIds, ], 'integration' => [ 'ldap_write_status' => 'planned', diff --git a/src/Desktop/AppRegistry.php b/src/Desktop/AppRegistry.php index c1e38887..9125878d 100644 --- a/src/Desktop/AppRegistry.php +++ b/src/Desktop/AppRegistry.php @@ -11,12 +11,27 @@ final class AppRegistry /** * @param array> $additionalApps + * @param array> $accessOverrides */ - public function __construct(string $configPath, array $additionalApps = []) + public function __construct(string $configPath, array $additionalApps = [], array $accessOverrides = []) { /** @var array> $apps */ $apps = require $configPath; - $this->apps = array_values(array_merge($apps, $additionalApps)); + $mergedApps = array_values(array_merge($apps, $additionalApps)); + + $this->apps = array_map(static function (array $app) use ($accessOverrides): array { + $appId = (string) ($app['app_id'] ?? ''); + if ($appId === '') { + return $app; + } + + $override = $accessOverrides[$appId] ?? null; + if (is_array($override) && array_key_exists('required_groups', $override)) { + $app['required_groups'] = array_values(array_map('strval', (array) ($override['required_groups'] ?? []))); + } + + return $app; + }, $mergedApps); } /** diff --git a/system/apps/admin-apps/assets/app.css b/system/apps/admin-apps/assets/app.css index 42df1a69..2a98e614 100644 --- a/system/apps/admin-apps/assets/app.css +++ b/system/apps/admin-apps/assets/app.css @@ -195,6 +195,37 @@ gap: 8px; } +#admin-apps-app .aa-access-card { + gap: 16px; +} + +#admin-apps-app .aa-access-groups { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +#admin-apps-app .aa-group-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); +} + +#admin-apps-app .aa-group-item input { + margin-top: 4px; +} + +#admin-apps-app .aa-group-main strong { + display: block; + margin-bottom: 4px; + font-size: 14px; +} + #admin-apps-app .aa-message { padding: 14px 16px; border-radius: 14px; diff --git a/system/apps/admin-apps/page.php b/system/apps/admin-apps/page.php index 6cebcf1f..d614f589 100644 --- a/system/apps/admin-apps/page.php +++ b/system/apps/admin-apps/page.php @@ -53,7 +53,7 @@ $service = new AdminAppsService($projectRoot); $messages = []; $errors = []; $section = trim((string) ($_GET['section'] ?? $_POST['active_section'] ?? 'overview')); -$allowedSections = ['overview', 'widgets', 'integrations']; +$allowedSections = ['overview', 'installation', 'access', 'widgets', 'integrations']; if (!in_array($section, $allowedSections, true)) { $section = 'overview'; } @@ -78,6 +78,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ]); $messages[] = 'Gitea-Deploy-Status-Konfiguration gespeichert.'; $section = 'integrations'; + } elseif ($action === 'save-app-access') { + $service->saveAppAccess( + (string) ($_POST['app_id'] ?? ''), + array_values(array_map('strval', (array) ($_POST['required_groups'] ?? []))) + ); + $messages[] = 'LDAP-Gruppen fuer die App wurden gespeichert.'; + $section = 'access'; } } catch (\Throwable $exception) { $errors[] = $exception->getMessage(); @@ -90,6 +97,7 @@ $apps = is_array($bootstrap['apps'] ?? null) ? $bootstrap['apps'] : []; $widgets = is_array($bootstrap['widgets'] ?? null) ? $bootstrap['widgets'] : []; $stats = is_array($bootstrap['stats'] ?? null) ? $bootstrap['stats'] : []; $meta = is_array($bootstrap['meta'] ?? null) ? $bootstrap['meta'] : []; +$availableGroups = is_array($meta['available_groups'] ?? null) ? $meta['available_groups'] : []; $gitea = is_array($bootstrap['gitea'] ?? null) ? $bootstrap['gitea'] : []; $giteaConfig = is_array($gitea['config'] ?? null) ? $gitea['config'] : []; $giteaDiagnostics = is_array($gitea['diagnostics'] ?? null) ? $gitea['diagnostics'] : []; @@ -99,15 +107,27 @@ $sectionUrl = static fn (string $targetSection): string => '/admin/apps/?section $sectionMeta = [ 'overview' => [ 'label' => 'Apps', - 'title' => 'App-Bestand und Scope', - 'description' => 'Installierbare Fach-Apps, Systemtools und Core-Eintraege der Desktop-Shell im aktuellen Registry-Stand.', - 'nav' => 'Registry und Freigaben pruefen.', + 'title' => 'Apps und Bereitstellung', + 'description' => 'Apps sind allgemeine Anwendungen. Sie koennen als Menue-App, Tray-App, Widget-Quelle oder mit optionalem Desktop-Icon auftreten.', + 'nav' => 'Bestand und Installationsweg pruefen.', + ], + 'installation' => [ + 'label' => 'Installation', + 'title' => 'Installationswege fuer Apps', + 'description' => 'Installierbare Fach-Apps liegen unter custom/apps/. Upload- und Importwege werden hier als naechster Ausbauschritt vorbereitet.', + 'nav' => 'ZIP-, Verzeichnis- und Server-Workflow beschreiben.', + ], + 'access' => [ + 'label' => 'Berechtigungen', + 'title' => 'LDAP-Gruppen pro App', + 'description' => 'Admins steuern hier, welche LDAP-Gruppen eine App benutzen duerfen. Leere Auswahl bedeutet: fuer alle sichtbaren Benutzer.', + 'nav' => 'LDAP-Gruppen verwalten.', ], 'widgets' => [ 'label' => 'Widgets', - 'title' => 'Widget-Bereich und Quellen', - 'description' => 'Widget-Inventar aus globaler Registry und Modul-Manifesten mit Quellen, Launch-App und Sichtbarkeit.', - 'nav' => 'Widget-Quellen und Startpunkte pruefen.', + 'title' => 'Widgets und Quellen', + 'description' => 'Widgets sind kleine Desktop-Bereiche ohne klassische Fensterfunktionen. Sie koennen eigenstaendig oder Teil einer App sein.', + 'nav' => 'Widget-Quellen und Bedeutung pruefen.', ], 'integrations' => [ 'label' => 'Integrationen', @@ -230,6 +250,7 @@ ob_start(); Titel Scope Typ + LDAP-Gruppen Route Flags @@ -243,6 +264,7 @@ ob_start(); +
@@ -259,6 +281,108 @@ ob_start();
+ +
+
+
+

Installation

+

Aktueller Installationsweg

+

Neue Apps koennen aktuell als Modul-Verzeichnis unter custom/apps/ abgelegt werden. Ein eigener Upload-Bereich fuer ZIP-Dateien oder Verzeichnisse ist noch offen und wird als naechster Ausbauschritt vorbereitet.

+
+
+
+ + + +
+
+ Installierbare Apps + +
+
+ Systemtools + +
+
+ +
+
+
+

Installationswege

+

Aktueller und geplanter Ablauf

+

Installierbare Apps liegen im Projekt unter custom/apps/. Der Desktop erkennt neue Module automatisch ueber deren Desktop-Metadaten.

+
+
+ +
+
+ 1. Manuell ueber den Server +

Ein Modul-Verzeichnis wird nach custom/apps/<app>/ kopiert. Das ist der aktuell gueltige produktive Weg.

+
+
+ 2. Verzeichnis-Upload +

Ein Browser-Upload fuer ein vorbereitetes Modul-Verzeichnis ist fachlich vorgesehen, aber noch nicht implementiert.

+
+
+ 3. ZIP-Upload +

Ein ZIP-Import mit Entpacken und Validierung ist vorgesehen, benoetigt aber noch einen dedizierten Installations-Workflow.

+
+
+
+ + + +
+
+
+

Access Control

+

LDAP-Gruppen pro App

+

Gespeichert wird in . Die kurzen Gruppennamen entsprechen den LDAP-/Auth-Gruppen im Desktop.

+
+
+ +
+ +
+ + + + + +
+ +

+

Aktuell:

+
+ +
+ +

Keine LDAP-Gruppen aus der Registration-Konfiguration gefunden.

+ + + + + +
+ +
+ +
+
+ +
+
@@ -267,7 +391,7 @@ ob_start();

Widget Registry

Alle Widgets

-

Globale Widgets und modulbasierte Widgets werden hier gemeinsam sichtbar. Modul-Widgets kommen bevorzugt aus `module.json`.

+

Globale Widgets und modulbasierte Widgets werden hier gemeinsam sichtbar. Fachlich sind Widgets kleine Desktop-Elemente; technisch werden sie aktuell noch uebergangsweise im rechten Infobereich gerendert.

diff --git a/system/apps/user-self-management/api.php b/system/apps/user-self-management/api.php index 5799f2bb..42d325ca 100644 --- a/system/apps/user-self-management/api.php +++ b/system/apps/user-self-management/api.php @@ -5,6 +5,7 @@ declare(strict_types=1); require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; use App\AppPaths; +use App\AppAccessConfig; use App\ConfigLoader; use App\KeycloakAuth; use App\LdapProvisioner; @@ -30,7 +31,11 @@ if ($method === 'OPTIONS') { $keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak'); $auth = new KeycloakAuth($keycloakConfig); $moduleRegistry = new ModuleRegistry(AppPaths::customAppsRoot($projectRoot)); -$registry = new AppRegistry($projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); +$registry = new AppRegistry( + $projectRoot . '/config/apps.php', + $moduleRegistry->desktopApps(), + AppAccessConfig::load($projectRoot) +); $widgetRegistry = new WidgetRegistry($projectRoot . '/config/widgets.php', $moduleRegistry->widgets()); $service = new UserSelfManagementService($projectRoot); $registrationConfig = ConfigLoader::load($projectRoot, 'registration'); @@ -103,9 +108,25 @@ function requiresReload(array $before, array $after): bool $afterSkin = (string) ($after['desktop']['active_skin'] ?? ''); $beforeApps = array_values(array_map('strval', (array) ($before['apps']['enabled_ids'] ?? []))); $afterApps = array_values(array_map('strval', (array) ($after['apps']['enabled_ids'] ?? []))); + $beforeDesktopIcons = array_values(array_map('strval', (array) ($before['apps']['desktop_icon_ids'] ?? []))); + $afterDesktopIcons = array_values(array_map('strval', (array) ($after['apps']['desktop_icon_ids'] ?? []))); + $beforeTray = array_values(array_map('strval', (array) ($before['tray']['active_ids'] ?? []))); + $afterTray = array_values(array_map('strval', (array) ($after['tray']['active_ids'] ?? []))); + $beforeWidgets = array_values(array_map('strval', (array) ($before['widgets']['active_ids'] ?? []))); + $afterWidgets = array_values(array_map('strval', (array) ($after['widgets']['active_ids'] ?? []))); sort($beforeApps); sort($afterApps); + sort($beforeDesktopIcons); + sort($afterDesktopIcons); + sort($beforeTray); + sort($afterTray); + sort($beforeWidgets); + sort($afterWidgets); - return $beforeSkin !== $afterSkin || $beforeApps !== $afterApps; + return $beforeSkin !== $afterSkin + || $beforeApps !== $afterApps + || $beforeDesktopIcons !== $afterDesktopIcons + || $beforeTray !== $afterTray + || $beforeWidgets !== $afterWidgets; } diff --git a/system/apps/user-self-management/assets/app.css b/system/apps/user-self-management/assets/app.css index 686c543b..a919825a 100644 --- a/system/apps/user-self-management/assets/app.css +++ b/system/apps/user-self-management/assets/app.css @@ -73,6 +73,29 @@ margin-top: 4px; } +#user-self-management-app .usm-item--app { + gap: 10px; +} + +#user-self-management-app .usm-item-toggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +#user-self-management-app .usm-inline-check { + display: inline-flex; + gap: 8px; + align-items: center; + font-size: 13px; + color: #475569; +} + +#user-self-management-app .usm-inline-check input { + margin: 0; +} + #user-self-management-app .usm-item-main { min-width: 0; } diff --git a/system/apps/user-self-management/assets/app.js b/system/apps/user-self-management/assets/app.js index a77bc9b9..f44a4461 100644 --- a/system/apps/user-self-management/assets/app.js +++ b/system/apps/user-self-management/assets/app.js @@ -2,8 +2,9 @@ const SECTIONS = [ { id: 'desktop', label: 'Desktop Type' }, { id: 'profile', label: 'Benutzerdaten' }, - { id: 'apps', label: 'App-Installation' }, - { id: 'widgets', label: 'Infobereich' }, + { id: 'apps', label: 'Apps' }, + { id: 'tray', label: 'Tray-Apps' }, + { id: 'widgets', label: 'Widgets' }, ]; const escapeHtml = (value) => String(value) @@ -32,10 +33,14 @@ } if (sectionId === 'apps') { - return 'Bestimmt, welche Desktop-Apps fuer diesen Benutzer sichtbar bleiben.'; + return 'Aktiviert Anwendungen fuer das Startmenue und optional als Desktop-Icon.'; } - return 'Steuert, welche Elemente im rechten Infobereich des Desktops aktiv sind.'; + if (sectionId === 'tray') { + return 'Steuert Schnellaktionen und Visualisierungen neben der Uhr, sofern Zugriff besteht.'; + } + + return 'Kleine Desktop-Bereiche ohne Fensterfunktionen, die separat aktiviert werden koennen.'; }; const renderSkinChoices = (state) => state.bootstrap.options.skins.map((skin) => { @@ -68,20 +73,52 @@ const renderAppItems = (state) => state.bootstrap.options.apps.map((app) => { const checked = state.draft.apps.enabled_ids.includes(app.app_id); + const desktopIconChecked = state.draft.apps.desktop_icon_ids.includes(app.app_id); + + return ` +
+ + ${app.can_show_on_desktop ? ` + + ` : ''} +
+ `; + }).join(''); + + const renderTrayItems = (state) => state.bootstrap.options.tray_apps.map((app) => { + const checked = state.draft.tray.active_ids.includes(app.app_id); return ` `; @@ -101,7 +138,7 @@ ${escapeHtml(widget.title)}

${escapeHtml(widget.summary || '')}

- ${widget.default_enabled ? 'Standardbereich' : 'Optionaler Bereich'} + ${widget.default_enabled ? 'Standard-Widget' : 'Optionales Widget'}
`; @@ -166,8 +203,8 @@ return `

App-Auswahl

-

Sichtbare Desktop-Apps

-

Aenderungen an der App-Auswahl werden nach dem Speichern mit einem Desktop-Reload sauber uebernommen.

+

Apps im Menue und auf dem Desktop

+

Eine aktivierte App erscheint standardmaessig im Menue. Fuer geeignete Apps kann zusaetzlich ein Desktop-Icon aktiviert werden.

${renderAppItems(state)}
@@ -175,11 +212,24 @@ `; } + if (state.activeSection === 'tray') { + return ` +
+

Tray-Apps

+

Bereich neben der Uhr

+

Tray-Apps sind Schnellzugriffe oder Statusanzeigen im Bereich neben der Uhr. Sie koennen eigenstaendig oder zusammen mit der Haupt-App genutzt werden.

+
+ ${renderTrayItems(state)} +
+
+ `; + } + return `
-

Infobereich

-

Rechter Desktopbereich

-

Diese Inhalte liegen aktuell im rechten Infobereich des Desktops und werden hier benutzerbezogen ein- oder ausgeblendet.

+

Widgets

+

Kleine Desktop-Bereiche

+

Widgets sind kleine Desktop-Bereiche ohne klassische Fensterfunktionen. Aktivierung und Positionierung werden schrittweise ausgebaut.

${renderWidgetItems(state)}
@@ -205,7 +255,7 @@

Setup

User Self Management

-

Ein gemeinsamer Setup-Bereich fuer Desktop-Typ, Benutzerdaten, Apps und den rechten Infobereich.

+

Ein gemeinsamer Setup-Bereich fuer Desktop-Typ, Benutzerdaten, Apps, Tray-Apps und Widgets.

${SECTIONS.map((section) => ` @@ -299,6 +349,10 @@ }, apps: { enabled_ids: state.draft.apps.enabled_ids, + desktop_icon_ids: state.draft.apps.desktop_icon_ids, + }, + tray: { + active_ids: state.draft.tray.active_ids, }, widgets: { active_ids: state.draft.widgets.active_ids, @@ -306,7 +360,10 @@ }); const requiresReload = (before, after) => before.desktop.active_skin !== after.desktop.active_skin - || !sameSelection(before.apps.enabled_ids, after.apps.enabled_ids); + || !sameSelection(before.apps.enabled_ids, after.apps.enabled_ids) + || !sameSelection(before.apps.desktop_icon_ids, after.apps.desktop_icon_ids) + || !sameSelection(before.tray.active_ids, after.tray.active_ids) + || !sameSelection(before.widgets.active_ids, after.widgets.active_ids); const attachInteractions = (root, state, options) => { root.addEventListener('click', async (event) => { @@ -405,6 +462,29 @@ target.dataset.appId, target.checked ); + if (!target.checked) { + state.draft.apps.desktop_icon_ids = state.draft.apps.desktop_icon_ids.filter((appId) => appId !== target.dataset.appId); + } + renderApp(root, state); + return; + } + + if (target.dataset.appDesktopIconId) { + state.draft.apps.desktop_icon_ids = syncSelectionWithCheckedState( + state.draft.apps.desktop_icon_ids, + target.dataset.appDesktopIconId, + target.checked + ); + renderApp(root, state); + return; + } + + if (target.dataset.trayAppId) { + state.draft.tray.active_ids = syncSelectionWithCheckedState( + state.draft.tray.active_ids, + target.dataset.trayAppId, + target.checked + ); renderApp(root, state); return; }