Files
desktop/src/App/UserSelfManagementService.php
Lars Gebhardt-Kusche 1286f2245a
All checks were successful
Deploy / deploy-staging (push) Successful in 23s
Deploy / deploy-production (push) Has been skipped
Change setting base
2026-06-20 01:21:10 +02:00

386 lines
13 KiB
PHP

<?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->readRawSettings($authUser),
$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->writeRawSettings($normalized, $authUser);
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'] ?? '') ?: $this->defaultSkin());
$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' => $this->repository()->available() ? 'base-db-user-data' : 'local-fallback',
'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 fileStore(string $scope): JsonStore
{
return new JsonStore($this->projectRoot . '/data/user-self-management/users/' . $scope . '.json');
}
private function repository(): DesktopUserPreferenceRepository
{
static $repository = null;
if ($repository instanceof DesktopUserPreferenceRepository) {
return $repository;
}
$repository = new DesktopUserPreferenceRepository(
ConfigLoader::load($this->projectRoot, 'base_db')
);
return $repository;
}
/**
* @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'] ?? $this->defaultSkin()),
$availableSkins
),
],
'apps' => [
'enabled_ids' => $enabledAppIds,
],
'widgets' => [
'active_ids' => $activeWidgetIds,
],
'integration' => [
'ldap_write_status' => 'planned',
'keycloak_write_status' => 'planned',
],
];
}
/**
* @param array<string, mixed> $authUser
* @return array<string, mixed>
*/
private function readRawSettings(array $authUser): array
{
$scope = $this->storageScope($authUser);
$repository = $this->repository();
if ($repository->available()) {
$stored = $repository->read($scope);
if (is_array($stored)) {
return $stored;
}
$legacy = $this->fileStore($scope)->read();
if ($legacy !== []) {
$repository->write($scope, $legacy, $authUser);
}
return $legacy;
}
return $this->fileStore($scope)->read();
}
/**
* @param array<string, mixed> $settings
* @param array<string, mixed> $authUser
*/
private function writeRawSettings(array $settings, array $authUser): void
{
$scope = $this->storageScope($authUser);
$repository = $this->repository();
if ($repository->available()) {
$repository->write($scope, $settings, $authUser);
return;
}
$this->fileStore($scope)->write($settings);
}
/**
* @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 : $this->defaultSkin();
}
private function defaultSkin(): string
{
return SkinResolver::resolve(null);
}
/**
* @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
{
if ($value === []) {
return true;
}
return array_keys($value) === range(0, count($value) - 1);
}
private function limitString(string $value, int $maxLength): string
{
$trimmed = trim($value);
if (function_exists('mb_substr')) {
return mb_substr($trimmed, 0, $maxLength);
}
return substr($trimmed, 0, $maxLength);
}
}