Self management
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-17 22:56:22 +02:00
parent a8853f9fd1
commit 6930a14d6b
15 changed files with 1475 additions and 76 deletions

View File

@@ -6,6 +6,7 @@ namespace App;
use Desktop\AppRegistry;
use Desktop\AppIconResolver;
use Desktop\AppVisibility;
use Desktop\DesktopState;
use Desktop\SkinResolver;
use Desktop\WidgetRegistry;
@@ -28,16 +29,30 @@ final class App
$registry = new AppRegistry($this->projectRoot . '/config/apps.php');
$widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php');
$skins = SkinResolver::all();
$activeSkin = SkinResolver::resolve($_GET['skin'] ?? null);
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
$authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null)
? $_SESSION['desktop_auth']['user']
: [];
$authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? [])));
$visibleApps = $this->filterAppsForGroups($registry->all(), $authGroups);
$apps = AppIconResolver::decorate($visibleApps, $activeSkin);
$visibleApps = AppVisibility::filterByGroups($registry->all(), $authGroups);
$userSelfManagement = new UserSelfManagementService($this->projectRoot);
$resolvedSkin = $userSelfManagement->resolveActiveSkin(
isset($_GET['skin']) ? (string) $_GET['skin'] : null,
$authUser,
$visibleApps,
$widgetRegistry->all(),
$skins
);
$userSettings = $resolvedSkin['settings'];
$activeSkin = (string) $resolvedSkin['skin'];
$enabledApps = AppVisibility::filterByEnabledIds(
$visibleApps,
array_values(array_map('strval', (array) ($userSettings['apps']['enabled_ids'] ?? []))),
[UserSelfManagementService::APP_ID]
);
$apps = AppIconResolver::decorate($enabledApps, $activeSkin);
$windows = (new WindowManager($registry))->defaultWindows();
$activeWidgetIds = DesktopState::defaultWidgetIds();
$activeWidgetIds = array_values(array_map('strval', (array) ($userSettings['widgets']['active_ids'] ?? DesktopState::defaultWidgetIds())));
$displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast');
$storageScope = $isAuthenticated
? strtolower((string) ($authUser['username'] ?? $authUser['sub'] ?? 'user'))
@@ -56,6 +71,8 @@ final class App
'wallpaper' => SkinResolver::wallpaper($activeSkin),
'storage_scope' => preg_replace('/[^a-z0-9._-]+/i', '-', $storageScope) ?: 'guest',
'workspace' => DesktopState::defaultWorkspace(),
'settings_api' => '/api/user-self-management/index.php',
'user_preferences' => $userSettings,
'login' => [
'authenticated' => $isAuthenticated,
'mode' => $isAuthenticated ? 'authenticated' : 'login-required',
@@ -85,30 +102,4 @@ final class App
],
];
}
/**
* @param array<int, array<string, mixed>> $apps
* @param array<int, string> $groups
* @return array<int, array<string, mixed>>
*/
private function filterAppsForGroups(array $apps, array $groups): array
{
$normalizedGroups = array_map(static fn (string $group): string => strtolower(trim($group, '/')), $groups);
return array_values(array_filter($apps, static function (array $app) use ($normalizedGroups): bool {
$requiredGroups = array_values(array_map('strval', (array) ($app['required_groups'] ?? [])));
if ($requiredGroups === []) {
return true;
}
foreach ($requiredGroups as $group) {
if (in_array(strtolower(trim($group, '/')), $normalizedGroups, true)) {
return true;
}
}
return false;
}));
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App;
use Desktop\SkinResolver;
final class UserSelfManagementService
{
public const APP_ID = 'user-self-management';
private const PROFILE_FIELDS = [
'name',
'email',
'phone',
'birthdate',
'title',
'department',
'city',
'website',
'notes',
];
public function __construct(
private readonly string $projectRoot,
) {
}
/**
* @param array<string, mixed> $authUser
* @param array<int, array<string, mixed>> $visibleApps
* @param array<int, array<string, mixed>> $widgets
* @param array<int, string> $availableSkins
* @return array<string, mixed>
*/
public function load(array $authUser, array $visibleApps, array $widgets, array $availableSkins): array
{
return $this->normalizeSettings(
$this->store($this->storageScope($authUser))->read(),
$authUser,
$visibleApps,
$widgets,
$availableSkins
);
}
/**
* @param array<string, mixed> $patch
* @param array<string, mixed> $authUser
* @param array<int, array<string, mixed>> $visibleApps
* @param array<int, array<string, mixed>> $widgets
* @param array<int, string> $availableSkins
* @return array<string, mixed>
*/
public function save(array $patch, array $authUser, array $visibleApps, array $widgets, array $availableSkins): array
{
$current = $this->load($authUser, $visibleApps, $widgets, $availableSkins);
$merged = $this->mergeRecursive($current, $patch);
$normalized = $this->normalizeSettings($merged, $authUser, $visibleApps, $widgets, $availableSkins);
$normalized['updated_at'] = gmdate(DATE_ATOM);
$this->store($this->storageScope($authUser))->write($normalized);
return $normalized;
}
/**
* @param array<string, mixed> $authUser
* @param array<int, array<string, mixed>> $visibleApps
* @param array<int, array<string, mixed>> $widgets
* @param array<int, string> $availableSkins
* @return array{skin: string, settings: array<string, mixed>}
*/
public function resolveActiveSkin(?string $requestedSkin, array $authUser, array $visibleApps, array $widgets, array $availableSkins): array
{
$settings = $this->load($authUser, $visibleApps, $widgets, $availableSkins);
$storedSkin = (string) (($settings['desktop']['active_skin'] ?? '') ?: SkinResolver::DEFAULT_SKIN);
$resolvedSkin = SkinResolver::resolve($requestedSkin !== null && $requestedSkin !== '' ? $requestedSkin : $storedSkin);
if ($resolvedSkin !== $storedSkin) {
$settings = $this->save(
['desktop' => ['active_skin' => $resolvedSkin]],
$authUser,
$visibleApps,
$widgets,
$availableSkins
);
}
return [
'skin' => $resolvedSkin,
'settings' => $settings,
];
}
/**
* @param array<string, mixed> $authUser
* @param array<int, array<string, mixed>> $visibleApps
* @param array<int, array<string, mixed>> $widgets
* @param array<int, string> $availableSkins
* @return array<string, mixed>
*/
public function buildBootstrapPayload(array $authUser, array $visibleApps, array $widgets, array $availableSkins): array
{
$settings = $this->load($authUser, $visibleApps, $widgets, $availableSkins);
$enabledAppIds = array_values(array_map('strval', (array) ($settings['apps']['enabled_ids'] ?? [])));
$activeWidgetIds = array_values(array_map('strval', (array) ($settings['widgets']['active_ids'] ?? [])));
return [
'preferences' => $settings,
'options' => [
'skins' => array_map(
static fn (string $skin): array => [
'skin_id' => $skin,
'label' => (string) (SkinResolver::profile($skin)['label'] ?? ucfirst($skin)),
],
$availableSkins
),
'apps' => array_map(
static fn (array $app): array => [
'app_id' => (string) ($app['app_id'] ?? ''),
'title' => (string) ($app['title'] ?? ''),
'summary' => (string) ($app['summary'] ?? ''),
'module_name' => (string) ($app['module_name'] ?? ''),
'required' => in_array((string) ($app['app_id'] ?? ''), [self::APP_ID], true),
'enabled' => in_array((string) ($app['app_id'] ?? ''), $enabledAppIds, true),
],
$visibleApps
),
'widgets' => array_map(
static fn (array $widget): array => [
'widget_id' => (string) ($widget['widget_id'] ?? ''),
'title' => (string) ($widget['title'] ?? ''),
'summary' => (string) ($widget['summary'] ?? ''),
'default_enabled' => (bool) ($widget['default_enabled'] ?? false),
'enabled' => in_array((string) ($widget['widget_id'] ?? ''), $activeWidgetIds, true),
],
$widgets
),
],
'meta' => [
'storage_scope' => $this->storageScope($authUser),
'authenticated' => ($authUser['sub'] ?? '') !== '' || ($authUser['username'] ?? '') !== '',
'sync_mode' => 'local-profile',
'sync_targets' => [
'ldap' => 'planned',
'keycloak' => 'planned',
],
],
];
}
/**
* @param array<string, mixed> $authUser
*/
public function storageScope(array $authUser): string
{
$rawScope = (string) ($authUser['username'] ?? $authUser['sub'] ?? 'guest');
$sanitized = preg_replace('/[^a-z0-9._-]+/i', '-', strtolower($rawScope));
return $sanitized !== null && $sanitized !== '' ? $sanitized : 'guest';
}
private function store(string $scope): JsonStore
{
return new JsonStore($this->projectRoot . '/data/user-self-management/users/' . $scope . '.json');
}
/**
* @param array<string, mixed> $settings
* @param array<string, mixed> $authUser
* @param array<int, array<string, mixed>> $visibleApps
* @param array<int, array<string, mixed>> $widgets
* @param array<int, string> $availableSkins
* @return array<string, mixed>
*/
private function normalizeSettings(array $settings, array $authUser, array $visibleApps, array $widgets, array $availableSkins): array
{
$visibleAppIds = array_values(array_filter(array_map(
static fn (array $app): string => (string) ($app['app_id'] ?? ''),
$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'] ?? ''),
$widgets
)));
$enabledAppIds = $this->sanitizeSelection(
$settings['apps']['enabled_ids'] ?? $visibleAppIds,
$visibleAppIds,
[self::APP_ID]
);
$activeWidgetIds = $this->sanitizeSelection(
$settings['widgets']['active_ids'] ?? $defaultWidgetIds,
$allWidgetIds
);
$profile = [];
foreach (self::PROFILE_FIELDS as $field) {
$fallback = match ($field) {
'name' => (string) ($authUser['name'] ?? ''),
'email' => (string) ($authUser['email'] ?? ''),
default => '',
};
$profile[$field] = $this->limitString(
(string) (($settings['profile'][$field] ?? '') ?: $fallback),
$field === 'notes' ? 2000 : 255
);
}
return [
'schema_version' => 1,
'updated_at' => (string) ($settings['updated_at'] ?? ''),
'profile' => $profile,
'desktop' => [
'active_skin' => $this->sanitizeSkin(
(string) ($settings['desktop']['active_skin'] ?? SkinResolver::DEFAULT_SKIN),
$availableSkins
),
],
'apps' => [
'enabled_ids' => $enabledAppIds,
],
'widgets' => [
'active_ids' => $activeWidgetIds,
],
'integration' => [
'ldap_write_status' => 'planned',
'keycloak_write_status' => 'planned',
],
];
}
/**
* @param mixed $selected
* @param array<int, string> $allowedIds
* @param array<int, string> $mandatoryIds
* @return array<int, string>
*/
private function sanitizeSelection(mixed $selected, array $allowedIds, array $mandatoryIds = []): array
{
$allowedLookup = array_fill_keys($allowedIds, true);
$result = [];
foreach ((array) $selected as $id) {
$stringId = (string) $id;
if ($stringId !== '' && isset($allowedLookup[$stringId])) {
$result[$stringId] = true;
}
}
foreach ($mandatoryIds as $mandatoryId) {
if ($mandatoryId !== '' && isset($allowedLookup[$mandatoryId])) {
$result[$mandatoryId] = true;
}
}
return array_keys($result);
}
/**
* @param array<int, string> $availableSkins
*/
private function sanitizeSkin(string $skin, array $availableSkins): string
{
$resolved = SkinResolver::resolve($skin);
return in_array($resolved, $availableSkins, true) ? $resolved : SkinResolver::DEFAULT_SKIN;
}
/**
* @param array<string, mixed> $base
* @param array<string, mixed> $patch
* @return array<string, mixed>
*/
private function mergeRecursive(array $base, array $patch): array
{
foreach ($patch as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key]) && !$this->isList($value)) {
/** @var array<string, mixed> $baseItem */
$baseItem = $base[$key];
/** @var array<string, mixed> $patchItem */
$patchItem = $value;
$base[$key] = $this->mergeRecursive($baseItem, $patchItem);
continue;
}
$base[$key] = $value;
}
return $base;
}
/**
* @param array<mixed> $value
*/
private function isList(array $value): bool
{
return array_keys($value) === range(0, count($value) - 1);
}
private function limitString(string $value, int $maxLength): string
{
$trimmed = trim($value);
return mb_substr($trimmed, 0, $maxLength);
}
}