Self management
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
Umsetzungsanweisung/Old-Nexus/
|
Umsetzungsanweisung/Old-Nexus/
|
||||||
|
data/
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ return [
|
|||||||
'module_name' => 'desktop',
|
'module_name' => 'desktop',
|
||||||
'summary' => 'Vorbereitung fuer mehrere persoenliche Dashboards und Workspace-Verwaltung.',
|
'summary' => 'Vorbereitung fuer mehrere persoenliche Dashboards und Workspace-Verwaltung.',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'app_id' => 'user-self-management',
|
||||||
|
'title' => 'User Self Management',
|
||||||
|
'icon' => 'US',
|
||||||
|
'entry_route' => '/apps/user-self-management',
|
||||||
|
'window_mode' => 'window',
|
||||||
|
'content_mode' => 'native-module',
|
||||||
|
'default_width' => 980,
|
||||||
|
'default_height' => 700,
|
||||||
|
'supports_widget' => true,
|
||||||
|
'supports_tray' => false,
|
||||||
|
'module_name' => 'desktop',
|
||||||
|
'summary' => 'Persoenliche Einstellungen fuer Desktop-Typ, Benutzerdaten, Apps und Widgets in einem gemeinsamen Setup-Bereich.',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'app_id' => 'admin-apps',
|
'app_id' => 'admin-apps',
|
||||||
'title' => 'Admin Apps',
|
'title' => 'Admin Apps',
|
||||||
|
|||||||
@@ -3,6 +3,18 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
[
|
||||||
|
'widget_id' => 'setup-launcher',
|
||||||
|
'title' => 'Setup',
|
||||||
|
'icon' => 'ST',
|
||||||
|
'zone' => 'sidebar',
|
||||||
|
'default_enabled' => true,
|
||||||
|
'supports_public_home' => true,
|
||||||
|
'summary' => 'Startet die persoenlichen Desktop-Einstellungen fuer Skin, Apps, Widgets und Benutzerdaten.',
|
||||||
|
'content' => 'Oeffnet den zentralen Setup-Bereich mit linker Navigation und lokaler Persistenz pro Benutzer.',
|
||||||
|
'launch_app_id' => 'user-self-management',
|
||||||
|
'action_label' => 'Setup oeffnen',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'widget_id' => 'public-home',
|
'widget_id' => 'public-home',
|
||||||
'title' => 'Public Home',
|
'title' => 'Public Home',
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ $assetVersion = static function (string $publicPath): string {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$hasMiningChecker = false;
|
$hasMiningChecker = false;
|
||||||
|
$hasUserSelfManagement = false;
|
||||||
foreach ($desktopPayload['apps'] as $desktopApp) {
|
foreach ($desktopPayload['apps'] as $desktopApp) {
|
||||||
if (($desktopApp['app_id'] ?? null) === 'mining-checker') {
|
if (($desktopApp['app_id'] ?? null) === 'mining-checker') {
|
||||||
$hasMiningChecker = true;
|
$hasMiningChecker = true;
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
if (($desktopApp['app_id'] ?? null) === 'user-self-management') {
|
||||||
|
$hasUserSelfManagement = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +75,6 @@ $renderStartIcon = static function (array $profile): string {
|
|||||||
<span>Fenster</span>
|
<span>Fenster</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="system-bar-right">
|
<div class="system-bar-right">
|
||||||
<div class="tray-skins system-skins" id="tray-skins-top"></div>
|
|
||||||
<span class="system-divider"></span>
|
|
||||||
<time id="clock-top"></time>
|
<time id="clock-top"></time>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -135,7 +137,6 @@ $renderStartIcon = static function (array $profile): string {
|
|||||||
<?php if (($desktopPayload['session']['authenticated'] ?? false) === true && !empty($desktopPayload['session']['logout_url'])): ?>
|
<?php if (($desktopPayload['session']['authenticated'] ?? false) === true && !empty($desktopPayload['session']['logout_url'])): ?>
|
||||||
<a class="tray-action" href="<?= htmlspecialchars((string) $desktopPayload['session']['logout_url'], ENT_QUOTES) ?>">Logout</a>
|
<a class="tray-action" href="<?= htmlspecialchars((string) $desktopPayload['session']['logout_url'], ENT_QUOTES) ?>">Logout</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="tray-skins" id="tray-skins"></div>
|
|
||||||
<time id="clock"></time>
|
<time id="clock"></time>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -156,5 +157,9 @@ $renderStartIcon = static function (array $profile): string {
|
|||||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.js'), ENT_QUOTES) ?>"></script>
|
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.js'), ENT_QUOTES) ?>"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if ($hasUserSelfManagement): ?>
|
||||||
|
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.css'), ENT_QUOTES) ?>">
|
||||||
|
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.js'), ENT_QUOTES) ?>"></script>
|
||||||
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
89
public/api/user-self-management/index.php
Normal file
89
public/api/user-self-management/index.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||||
|
|
||||||
|
use App\ConfigLoader;
|
||||||
|
use App\KeycloakAuth;
|
||||||
|
use App\UserSelfManagementService;
|
||||||
|
use Desktop\AppRegistry;
|
||||||
|
use Desktop\AppVisibility;
|
||||||
|
use Desktop\SkinResolver;
|
||||||
|
use Desktop\WidgetRegistry;
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$projectRoot = dirname(__DIR__, 3);
|
||||||
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
|
||||||
|
if ($method === 'OPTIONS') {
|
||||||
|
header('Allow: GET, PUT, OPTIONS');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak');
|
||||||
|
$auth = new KeycloakAuth($keycloakConfig);
|
||||||
|
$registry = new AppRegistry($projectRoot . '/config/apps.php');
|
||||||
|
$widgetRegistry = new WidgetRegistry($projectRoot . '/config/widgets.php');
|
||||||
|
$service = new UserSelfManagementService($projectRoot);
|
||||||
|
$isAuthenticated = $auth->isAuthenticated();
|
||||||
|
$authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null)
|
||||||
|
? $_SESSION['desktop_auth']['user']
|
||||||
|
: [];
|
||||||
|
$authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? [])));
|
||||||
|
$visibleApps = AppVisibility::filterByGroups($registry->all(), $authGroups);
|
||||||
|
$widgets = $widgetRegistry->all();
|
||||||
|
$skins = SkinResolver::all();
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
respond($service->buildBootstrapPayload($authUser, $visibleApps, $widgets, $skins));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method !== 'PUT') {
|
||||||
|
respond(['error' => 'Method not allowed.'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = file_get_contents('php://input');
|
||||||
|
$decoded = json_decode($rawBody !== false ? $rawBody : '', true);
|
||||||
|
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
respond(['error' => 'Invalid JSON payload.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = $service->load($authUser, $visibleApps, $widgets, $skins);
|
||||||
|
$after = $service->save($decoded, $authUser, $visibleApps, $widgets, $skins);
|
||||||
|
$payload = $service->buildBootstrapPayload($authUser, $visibleApps, $widgets, $skins);
|
||||||
|
$payload['preferences'] = $after;
|
||||||
|
$payload['meta']['requires_reload'] = requiresReload($before, $after);
|
||||||
|
|
||||||
|
respond($payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
function respond(array $payload, int $statusCode = 200): never
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $before
|
||||||
|
* @param array<string, mixed> $after
|
||||||
|
*/
|
||||||
|
function requiresReload(array $before, array $after): bool
|
||||||
|
{
|
||||||
|
$beforeSkin = (string) ($before['desktop']['active_skin'] ?? '');
|
||||||
|
$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'] ?? [])));
|
||||||
|
|
||||||
|
sort($beforeApps);
|
||||||
|
sort($afterApps);
|
||||||
|
|
||||||
|
return $beforeSkin !== $afterSkin || $beforeApps !== $afterApps;
|
||||||
|
}
|
||||||
39
public/apps/user-self-management/index.php
Normal file
39
public/apps/user-self-management/index.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$assetVersion = static function (string $publicPath): string {
|
||||||
|
$filesystemPath = dirname(__DIR__, 2) . $publicPath;
|
||||||
|
|
||||||
|
if (!is_file($filesystemPath)) {
|
||||||
|
return $publicPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = filemtime($filesystemPath);
|
||||||
|
|
||||||
|
return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime;
|
||||||
|
};
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>User Self Management</title>
|
||||||
|
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.css'), ENT_QUOTES) ?>">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#e5e7eb;">
|
||||||
|
<div id="user-self-management-app" style="min-height:100vh;"></div>
|
||||||
|
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.js'), ENT_QUOTES) ?>"></script>
|
||||||
|
<script>
|
||||||
|
if (typeof window.initUserSelfManagementApp === 'function') {
|
||||||
|
window.initUserSelfManagementApp(document.getElementById('user-self-management-app'), {
|
||||||
|
apiBase: '/api/user-self-management/index.php',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
309
public/assets/apps/user-self-management/app.css
Normal file
309
public/assets/apps/user-self-management/app.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
#user-self-management-app {
|
||||||
|
min-height: 100%;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app,
|
||||||
|
#user-self-management-app * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(241, 245, 249, 0.98)),
|
||||||
|
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-frame {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-nav {
|
||||||
|
padding: 22px 16px;
|
||||||
|
border-right: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-brand {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-brand h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-brand p,
|
||||||
|
#user-self-management-app .usm-copy,
|
||||||
|
#user-self-management-app .usm-meta,
|
||||||
|
#user-self-management-app .usm-note,
|
||||||
|
#user-self-management-app .usm-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-nav-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-nav-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-nav-button.is-active {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f8fafc;
|
||||||
|
border-color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-main {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-hero,
|
||||||
|
#user-self-management-app .usm-card,
|
||||||
|
#user-self-management-app .usm-actions {
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-kicker {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-pill-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-grid,
|
||||||
|
#user-self-management-app .usm-choice-grid,
|
||||||
|
#user-self-management-app .usm-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-choice-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-input,
|
||||||
|
#user-self-management-app .usm-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-textarea {
|
||||||
|
min-height: 112px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-input:focus,
|
||||||
|
#user-self-management-app .usm-textarea:focus {
|
||||||
|
outline: 2px solid rgba(37, 99, 235, 0.18);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-choice {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-choice.is-active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: inset 0 0 0 1px #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-choice strong,
|
||||||
|
#user-self-management-app .usm-item-main strong {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-choice p,
|
||||||
|
#user-self-management-app .usm-item-main p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #dbe2ea;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-item input {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-item-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #f8fafc;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-message.is-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-message.is-success {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-loading,
|
||||||
|
#user-self-management-app .usm-error-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#user-self-management-app .usm-frame {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-nav {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-grid,
|
||||||
|
#user-self-management-app .usm-choice-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-self-management-app .usm-hero,
|
||||||
|
#user-self-management-app .usm-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
449
public/assets/apps/user-self-management/app.js
Normal file
449
public/assets/apps/user-self-management/app.js
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
(() => {
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'desktop', label: 'Desktop Type' },
|
||||||
|
{ id: 'profile', label: 'Benutzerdaten' },
|
||||||
|
{ id: 'apps', label: 'App-Installation' },
|
||||||
|
{ id: 'widgets', label: 'Widget-Installation' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
const sameSelection = (left, right) => {
|
||||||
|
const nextLeft = [...left].sort();
|
||||||
|
const nextRight = [...right].sort();
|
||||||
|
|
||||||
|
return JSON.stringify(nextLeft) === JSON.stringify(nextRight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const summarizeSection = (sectionId) => {
|
||||||
|
if (sectionId === 'desktop') {
|
||||||
|
return 'Windows-aehnlicher Standard fuer Desktop-Typ und Skin-Auswahl.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionId === 'profile') {
|
||||||
|
return 'Lokales Profil fuer Name, E-Mail, Telefon und spaetere LDAP-/Keycloak-Synchronisierung.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionId === 'apps') {
|
||||||
|
return 'Bestimmt, welche Desktop-Apps fuer diesen Benutzer sichtbar bleiben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Steuert, welche Widgets in der rechten Leiste des Desktops aktiv sind.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSkinChoices = (state) => state.bootstrap.options.skins.map((skin) => {
|
||||||
|
const isActive = state.draft.desktop.active_skin === skin.skin_id;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button
|
||||||
|
class="usm-choice${isActive ? ' is-active' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-action="select-skin"
|
||||||
|
data-skin-id="${escapeHtml(skin.skin_id)}"
|
||||||
|
>
|
||||||
|
<strong>${escapeHtml(skin.label)}</strong>
|
||||||
|
<p>${escapeHtml(summarizeSkin(skin.skin_id))}</p>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const summarizeSkin = (skinId) => {
|
||||||
|
if (skinId === 'apple') {
|
||||||
|
return 'Top-Bar, rechte Icon-Anordnung und Finder-nahe Fensterelemente.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skinId === 'linux') {
|
||||||
|
return 'Klassische Arbeitsflaeche mit neutralem Fensterstil und Linux-Taskbar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Standardisiertes Control-Panel-Muster mit Windows-naher Einstellungslogik.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAppItems = (state) => state.bootstrap.options.apps.map((app) => {
|
||||||
|
const checked = state.draft.apps.enabled_ids.includes(app.app_id);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label class="usm-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-action="toggle-app"
|
||||||
|
data-app-id="${escapeHtml(app.app_id)}"
|
||||||
|
${checked ? 'checked' : ''}
|
||||||
|
${app.required ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
<span class="usm-item-main">
|
||||||
|
<strong>${escapeHtml(app.title)}</strong>
|
||||||
|
<p>${escapeHtml(app.summary || '')}</p>
|
||||||
|
<span class="usm-item-meta">${app.required ? 'Pflicht-App' : 'Optional sichtbar'}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const renderWidgetItems = (state) => state.bootstrap.options.widgets.map((widget) => {
|
||||||
|
const checked = state.draft.widgets.active_ids.includes(widget.widget_id);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label class="usm-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-action="toggle-widget"
|
||||||
|
data-widget-id="${escapeHtml(widget.widget_id)}"
|
||||||
|
${checked ? 'checked' : ''}
|
||||||
|
>
|
||||||
|
<span class="usm-item-main">
|
||||||
|
<strong>${escapeHtml(widget.title)}</strong>
|
||||||
|
<p>${escapeHtml(widget.summary || '')}</p>
|
||||||
|
<span class="usm-item-meta">${widget.default_enabled ? 'Standard-Widget' : 'Optionales Widget'}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const renderProfileFields = (state) => `
|
||||||
|
<div class="usm-grid">
|
||||||
|
${renderField('name', 'Name', state.draft.profile.name)}
|
||||||
|
${renderField('email', 'E-Mail', state.draft.profile.email, 'email')}
|
||||||
|
${renderField('phone', 'Telefon', state.draft.profile.phone, 'tel')}
|
||||||
|
${renderField('birthdate', 'Geburtsdatum', state.draft.profile.birthdate, 'date')}
|
||||||
|
${renderField('title', 'Titel', state.draft.profile.title)}
|
||||||
|
${renderField('department', 'Abteilung', state.draft.profile.department)}
|
||||||
|
${renderField('city', 'Ort', state.draft.profile.city)}
|
||||||
|
${renderField('website', 'Website', state.draft.profile.website, 'url')}
|
||||||
|
</div>
|
||||||
|
<div class="usm-field">
|
||||||
|
<label class="usm-label" for="usm-notes">Notizen</label>
|
||||||
|
<textarea id="usm-notes" class="usm-textarea" data-field="profile.notes">${escapeHtml(state.draft.profile.notes || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const renderField = (fieldId, label, value, type = 'text') => `
|
||||||
|
<div class="usm-field">
|
||||||
|
<label class="usm-label" for="usm-${escapeHtml(fieldId)}">${escapeHtml(label)}</label>
|
||||||
|
<input
|
||||||
|
id="usm-${escapeHtml(fieldId)}"
|
||||||
|
class="usm-input"
|
||||||
|
type="${escapeHtml(type)}"
|
||||||
|
data-field="profile.${escapeHtml(fieldId)}"
|
||||||
|
value="${escapeHtml(value || '')}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sectionContent = (state) => {
|
||||||
|
if (state.activeSection === 'desktop') {
|
||||||
|
return `
|
||||||
|
<section class="usm-card">
|
||||||
|
<p class="usm-kicker">Desktop Type</p>
|
||||||
|
<h2>Skin-Auswahl</h2>
|
||||||
|
<p class="usm-copy">Die direkte Skin-Umschaltung liegt jetzt im Setup. Die Auswahl wird pro Benutzer gespeichert.</p>
|
||||||
|
<div class="usm-choice-grid">
|
||||||
|
${renderSkinChoices(state)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeSection === 'profile') {
|
||||||
|
return `
|
||||||
|
<section class="usm-card">
|
||||||
|
<p class="usm-kicker">Benutzerdaten</p>
|
||||||
|
<h2>Persoenliches Profil</h2>
|
||||||
|
<p class="usm-copy">Diese Daten werden zunaechst lokal gespeichert und spaeter an LDAP oder Keycloak angebunden.</p>
|
||||||
|
${renderProfileFields(state)}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeSection === 'apps') {
|
||||||
|
return `
|
||||||
|
<section class="usm-card">
|
||||||
|
<p class="usm-kicker">App-Auswahl</p>
|
||||||
|
<h2>Sichtbare Desktop-Apps</h2>
|
||||||
|
<p class="usm-copy">Aenderungen an der App-Auswahl werden nach dem Speichern mit einem Desktop-Reload sauber uebernommen.</p>
|
||||||
|
<div class="usm-list">
|
||||||
|
${renderAppItems(state)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="usm-card">
|
||||||
|
<p class="usm-kicker">Widget-Auswahl</p>
|
||||||
|
<h2>Aktive Widget-Leiste</h2>
|
||||||
|
<p class="usm-copy">Widgets bleiben Teil der Desktop-Mechanik und werden hier nur benutzerbezogen ein- oder ausgeblendet.</p>
|
||||||
|
<div class="usm-list">
|
||||||
|
${renderWidgetItems(state)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderApp = (root, state) => {
|
||||||
|
if (state.loading) {
|
||||||
|
root.innerHTML = '<div class="usm-loading"><p class="usm-copy">Einstellungen werden geladen.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error && !state.bootstrap) {
|
||||||
|
root.innerHTML = `<div class="usm-error-state"><p class="usm-message is-error">${escapeHtml(state.error)}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="usm-shell">
|
||||||
|
<div class="usm-frame">
|
||||||
|
<aside class="usm-nav">
|
||||||
|
<div class="usm-brand">
|
||||||
|
<p class="usm-kicker">Setup</p>
|
||||||
|
<h1>User Self Management</h1>
|
||||||
|
<p>Ein gemeinsamer Setup-Bereich fuer Desktop-Typ, Benutzerdaten, Apps und Widgets.</p>
|
||||||
|
</div>
|
||||||
|
<div class="usm-nav-list">
|
||||||
|
${SECTIONS.map((section) => `
|
||||||
|
<button
|
||||||
|
class="usm-nav-button${state.activeSection === section.id ? ' is-active' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-action="open-section"
|
||||||
|
data-section-id="${escapeHtml(section.id)}"
|
||||||
|
>
|
||||||
|
<strong>${escapeHtml(section.label)}</strong>
|
||||||
|
<span class="usm-meta">${escapeHtml(summarizeSection(section.id))}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="usm-main">
|
||||||
|
<div class="usm-panel">
|
||||||
|
<section class="usm-hero">
|
||||||
|
<div>
|
||||||
|
<p class="usm-kicker">Desktop Setup</p>
|
||||||
|
<h2 class="usm-title">${escapeHtml(sectionTitle(state.activeSection))}</h2>
|
||||||
|
<p class="usm-copy">${escapeHtml(summarizeSection(state.activeSection))}</p>
|
||||||
|
</div>
|
||||||
|
<div class="usm-pill-row">
|
||||||
|
<span class="usm-pill">LDAP: geplant</span>
|
||||||
|
<span class="usm-pill">Keycloak: geplant</span>
|
||||||
|
<span class="usm-pill">Scope: ${escapeHtml(state.bootstrap.meta.storage_scope || 'guest')}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
${sectionContent(state)}
|
||||||
|
<section class="usm-actions">
|
||||||
|
<div>
|
||||||
|
<p class="usm-note">Skin- und App-Aenderungen koennen einen Reload ausloesen, damit Fenster, Icons und Menue sauber synchron bleiben.</p>
|
||||||
|
<p class="usm-message${state.error ? ' is-error' : state.success ? ' is-success' : ''}">${escapeHtml(state.error || state.success || '')}</p>
|
||||||
|
</div>
|
||||||
|
<button class="usm-button" type="button" data-action="save-settings" ${state.saving ? 'disabled' : ''}>
|
||||||
|
${state.saving ? 'Speichert ...' : 'Aenderungen speichern'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitle = (sectionId) => {
|
||||||
|
const current = SECTIONS.find((section) => section.id === sectionId);
|
||||||
|
return current ? current.label : 'Setup';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDraftValue = (draft, path, value) => {
|
||||||
|
const segments = path.split('.');
|
||||||
|
let cursor = draft;
|
||||||
|
|
||||||
|
segments.slice(0, -1).forEach((segment) => {
|
||||||
|
cursor = cursor[segment];
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor[segments[segments.length - 1]] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelection = (collection, value) => {
|
||||||
|
const next = new Set(collection);
|
||||||
|
|
||||||
|
if (next.has(value)) {
|
||||||
|
next.delete(value);
|
||||||
|
} else {
|
||||||
|
next.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPatch = (state) => ({
|
||||||
|
profile: state.draft.profile,
|
||||||
|
desktop: {
|
||||||
|
active_skin: state.draft.desktop.active_skin,
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
enabled_ids: state.draft.apps.enabled_ids,
|
||||||
|
},
|
||||||
|
widgets: {
|
||||||
|
active_ids: state.draft.widgets.active_ids,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiresReload = (before, after) => before.desktop.active_skin !== after.desktop.active_skin
|
||||||
|
|| !sameSelection(before.apps.enabled_ids, after.apps.enabled_ids);
|
||||||
|
|
||||||
|
const attachInteractions = (root, state, options) => {
|
||||||
|
root.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target instanceof Element ? event.target.closest('[data-action]') : null;
|
||||||
|
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = button.dataset.action;
|
||||||
|
|
||||||
|
if (action === 'open-section') {
|
||||||
|
state.activeSection = button.dataset.sectionId || 'desktop';
|
||||||
|
renderApp(root, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'select-skin') {
|
||||||
|
state.draft.desktop.active_skin = button.dataset.skinId || state.draft.desktop.active_skin;
|
||||||
|
renderApp(root, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'save-settings') {
|
||||||
|
if (!options.apiBase || state.saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.saving = true;
|
||||||
|
state.error = '';
|
||||||
|
state.success = '';
|
||||||
|
renderApp(root, state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const before = clone(state.bootstrap.preferences);
|
||||||
|
const response = await window.fetch(options.apiBase, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildPatch(state)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`save-failed:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const after = payload.preferences;
|
||||||
|
state.bootstrap = payload;
|
||||||
|
state.draft = clone(after);
|
||||||
|
state.success = payload.meta?.requires_reload
|
||||||
|
? 'Gespeichert. Desktop wird fuer Skin- oder App-Aenderungen neu geladen.'
|
||||||
|
: 'Einstellungen wurden gespeichert.';
|
||||||
|
state.saving = false;
|
||||||
|
renderApp(root, state);
|
||||||
|
|
||||||
|
if (window.desktopShell?.dispatchUserSettingsUpdate) {
|
||||||
|
window.desktopShell.dispatchUserSettingsUpdate({
|
||||||
|
preferences: after,
|
||||||
|
requiresReload: payload.meta?.requires_reload || requiresReload(before, after),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
state.saving = false;
|
||||||
|
state.error = 'Speichern fehlgeschlagen. Die lokalen Einstellungen bleiben unveraendert.';
|
||||||
|
renderApp(root, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener('change', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.dataset.field) {
|
||||||
|
setDraftValue(state.draft, target.dataset.field, target.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.dataset.appId) {
|
||||||
|
state.draft.apps.enabled_ids = toggleSelection(state.draft.apps.enabled_ids, target.dataset.appId);
|
||||||
|
renderApp(root, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.dataset.widgetId) {
|
||||||
|
state.draft.widgets.active_ids = toggleSelection(state.draft.widgets.active_ids, target.dataset.widgetId);
|
||||||
|
renderApp(root, state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.initUserSelfManagementApp = (root, options = {}) => {
|
||||||
|
if (!(root instanceof HTMLElement) || root.dataset.usmInitialized === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.dataset.usmInitialized = 'true';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
activeSection: 'desktop',
|
||||||
|
bootstrap: null,
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
attachInteractions(root, state, options);
|
||||||
|
renderApp(root, state);
|
||||||
|
|
||||||
|
if (!options.apiBase) {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = 'Die Setup-API ist nicht konfiguriert.';
|
||||||
|
renderApp(root, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetch(options.apiBase, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`load-failed:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.bootstrap = payload;
|
||||||
|
state.draft = clone(payload.preferences);
|
||||||
|
renderApp(root, state);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = 'Die Einstellungen konnten nicht geladen werden.';
|
||||||
|
renderApp(root, state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -284,6 +284,28 @@ h1 {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-card-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-card-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
color: #f8fafc;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-card-action:hover {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.widget-badge {
|
.widget-badge {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ if (payloadNode) {
|
|||||||
const startMenuApps = document.getElementById('start-menu-apps');
|
const startMenuApps = document.getElementById('start-menu-apps');
|
||||||
const startMenuWidgets = document.getElementById('start-menu-widgets');
|
const startMenuWidgets = document.getElementById('start-menu-widgets');
|
||||||
const startMenuActions = document.getElementById('start-menu-actions');
|
const startMenuActions = document.getElementById('start-menu-actions');
|
||||||
const traySkins = document.getElementById('tray-skins');
|
|
||||||
const traySkinsTop = document.getElementById('tray-skins-top');
|
|
||||||
const clockNode = document.getElementById('clock');
|
const clockNode = document.getElementById('clock');
|
||||||
const clockTopNode = document.getElementById('clock-top');
|
const clockTopNode = document.getElementById('clock-top');
|
||||||
const windows = new Map();
|
const windows = new Map();
|
||||||
const activeWidgets = new Set(payload.widgets.active_ids);
|
const activeWidgets = new Set(payload.widgets.active_ids);
|
||||||
const activeSkin = payload.meta.active_skin;
|
const activeSkin = payload.meta.active_skin;
|
||||||
const skinProfile = payload.meta.skin_profile || {};
|
const skinProfile = payload.meta.skin_profile || {};
|
||||||
|
const settingsApi = payload.desktop.settings_api || '';
|
||||||
|
let currentUserPreferences = payload.desktop.user_preferences || {};
|
||||||
const maximizeOverSystemBar = Boolean(skinProfile.maximize_over_system_bar);
|
const maximizeOverSystemBar = Boolean(skinProfile.maximize_over_system_bar);
|
||||||
const storageScope = payload.desktop.storage_scope || 'guest';
|
const storageScope = payload.desktop.storage_scope || 'guest';
|
||||||
const storageKey = `desktop.kusche.berlin.window-state.${storageScope}`;
|
const storageKey = `desktop.kusche.berlin.window-state.${storageScope}`;
|
||||||
@@ -172,13 +172,40 @@ if (payloadNode) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = payload.session?.display_name;
|
const displayName = currentUserPreferences?.profile?.name || payload.session?.display_name;
|
||||||
|
|
||||||
if (typeof displayName === 'string' && displayName.trim() !== '') {
|
if (typeof displayName === 'string' && displayName.trim() !== '') {
|
||||||
accountNode.textContent = displayName;
|
accountNode.textContent = displayName;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findAppById = (appId) => payload.apps.find((app) => app.app_id === appId) || null;
|
||||||
|
|
||||||
|
const dispatchUserSettingsUpdate = (detail) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('desktop:user-settings-updated', { detail }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistSettingsPatch = async (patch) => {
|
||||||
|
if (!settingsApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await window.fetch(settingsApi, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`settings-save-failed:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
const buildTaskbarButtonContent = (record) => `
|
const buildTaskbarButtonContent = (record) => `
|
||||||
<span class="taskbar-app-button-badge">${buildAppIconMarkup(record.definition, 'taskbar-app-icon')}</span>
|
<span class="taskbar-app-button-badge">${buildAppIconMarkup(record.definition, 'taskbar-app-icon')}</span>
|
||||||
<span class="taskbar-app-button-label">${escapeHtml(record.title)}</span>
|
<span class="taskbar-app-button-label">${escapeHtml(record.title)}</span>
|
||||||
@@ -236,6 +263,14 @@ if (payloadNode) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') {
|
||||||
|
return `
|
||||||
|
<div class="window-content window-content--embedded window-content--module">
|
||||||
|
<div id="user-self-management-app" class="window-module-host" data-module-app="user-self-management"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (definition.content_mode === 'iframe' && typeof definition.entry_route === 'string' && definition.entry_route !== '') {
|
if (definition.content_mode === 'iframe' && typeof definition.entry_route === 'string' && definition.entry_route !== '') {
|
||||||
const title = escapeHtml(definition.title);
|
const title = escapeHtml(definition.title);
|
||||||
const src = escapeHtml(definition.entry_route);
|
const src = escapeHtml(definition.entry_route);
|
||||||
@@ -557,30 +592,6 @@ if (payloadNode) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToSkin = (skin) => {
|
|
||||||
window.location.search = `?skin=${encodeURIComponent(skin)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTraySkins = () => {
|
|
||||||
[traySkins, traySkinsTop].forEach((container) => {
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
payload.meta.available_skins.forEach((skin) => {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.className = `tray-skin-button${skin === activeSkin ? ' is-active' : ''}`;
|
|
||||||
button.textContent = skin.slice(0, 3).toUpperCase();
|
|
||||||
button.title = skin;
|
|
||||||
button.addEventListener('click', () => navigateToSkin(skin));
|
|
||||||
container.appendChild(button);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const focusWindow = (windowId) => {
|
const focusWindow = (windowId) => {
|
||||||
windows.forEach((record) => {
|
windows.forEach((record) => {
|
||||||
record.node.classList.remove('is-focused');
|
record.node.classList.remove('is-focused');
|
||||||
@@ -993,6 +1004,17 @@ if (payloadNode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') {
|
||||||
|
if (typeof window.initUserSelfManagementApp === 'function') {
|
||||||
|
window.initUserSelfManagementApp(moduleHost, {
|
||||||
|
apiBase: settingsApi,
|
||||||
|
activeSkin,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
moduleHost.innerHTML = '<div class="window-module-error">User Self Management konnte nicht initialisiert werden.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
node.querySelectorAll('.window-action').forEach((action) => {
|
node.querySelectorAll('.window-action').forEach((action) => {
|
||||||
action.addEventListener('click', (event) => {
|
action.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1060,6 +1082,15 @@ if (payloadNode) {
|
|||||||
.forEach((widget) => {
|
.forEach((widget) => {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'widget-card';
|
card.className = 'widget-card';
|
||||||
|
const actionMarkup = widget.launch_app_id
|
||||||
|
? `
|
||||||
|
<div class="widget-card-actions">
|
||||||
|
<button class="widget-card-action" type="button" data-app-id="${escapeHtml(widget.launch_app_id)}">
|
||||||
|
${escapeHtml(widget.action_label || 'Oeffnen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="widget-card-header">
|
<div class="widget-card-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1069,7 +1100,19 @@ if (payloadNode) {
|
|||||||
<span class="widget-badge">${widget.icon || 'WG'}</span>
|
<span class="widget-badge">${widget.icon || 'WG'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>${widget.content || ''}</p>
|
<p>${widget.content || ''}</p>
|
||||||
|
${actionMarkup}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const actionButton = card.querySelector('.widget-card-action');
|
||||||
|
if (actionButton instanceof HTMLButtonElement && widget.launch_app_id) {
|
||||||
|
actionButton.addEventListener('click', () => {
|
||||||
|
const app = findAppById(widget.launch_app_id);
|
||||||
|
if (app) {
|
||||||
|
openApp(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
widgetZoneRoot.appendChild(card);
|
widgetZoneRoot.appendChild(card);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1104,14 +1147,30 @@ if (payloadNode) {
|
|||||||
|
|
||||||
renderWidgets();
|
renderWidgets();
|
||||||
renderMenuWidgets();
|
renderMenuWidgets();
|
||||||
|
persistSettingsPatch({
|
||||||
|
widgets: {
|
||||||
|
active_ids: Array.from(activeWidgets),
|
||||||
|
},
|
||||||
|
}).catch(() => {
|
||||||
|
// Keep current desktop state even if persistence fails.
|
||||||
|
});
|
||||||
});
|
});
|
||||||
startMenuWidgets.appendChild(button);
|
startMenuWidgets.appendChild(button);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const runQuickAction = (actionId) => {
|
const runQuickAction = (actionId) => {
|
||||||
|
if (actionId === 'open-user-self-management') {
|
||||||
|
const userSetup = findAppById('user-self-management');
|
||||||
|
if (userSetup) {
|
||||||
|
openApp(userSetup);
|
||||||
|
closeStartMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (actionId === 'open-public-dashboard') {
|
if (actionId === 'open-public-dashboard') {
|
||||||
const publicDashboard = payload.apps.find((app) => app.app_id === 'public-dashboard');
|
const publicDashboard = findAppById('public-dashboard');
|
||||||
if (publicDashboard) {
|
if (publicDashboard) {
|
||||||
openApp(publicDashboard);
|
openApp(publicDashboard);
|
||||||
}
|
}
|
||||||
@@ -1128,14 +1187,13 @@ if (payloadNode) {
|
|||||||
|
|
||||||
renderWidgets();
|
renderWidgets();
|
||||||
renderMenuWidgets();
|
renderMenuWidgets();
|
||||||
return;
|
persistSettingsPatch({
|
||||||
}
|
widgets: {
|
||||||
|
active_ids: Array.from(activeWidgets),
|
||||||
if (actionId === 'cycle-skin') {
|
},
|
||||||
const skins = payload.meta.available_skins;
|
}).catch(() => {
|
||||||
const currentIndex = skins.indexOf(activeSkin);
|
// Keep current desktop state even if persistence fails.
|
||||||
const nextSkin = skins[(currentIndex + 1) % skins.length];
|
});
|
||||||
navigateToSkin(nextSkin);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1235,9 +1293,33 @@ if (payloadNode) {
|
|||||||
setStartMenuOpen(isClosed);
|
setStartMenuOpen(isClosed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('desktop:user-settings-updated', (event) => {
|
||||||
|
const preferences = event.detail?.preferences;
|
||||||
|
const requiresReload = Boolean(event.detail?.requiresReload);
|
||||||
|
|
||||||
|
if (preferences && typeof preferences === 'object') {
|
||||||
|
currentUserPreferences = preferences;
|
||||||
|
|
||||||
|
const nextWidgetIds = Array.isArray(preferences.widgets?.active_ids)
|
||||||
|
? preferences.widgets.active_ids
|
||||||
|
: [];
|
||||||
|
|
||||||
|
activeWidgets.clear();
|
||||||
|
nextWidgetIds.forEach((widgetId) => activeWidgets.add(widgetId));
|
||||||
|
renderWidgets();
|
||||||
|
renderMenuWidgets();
|
||||||
|
updateSessionDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresReload) {
|
||||||
|
const nextSkin = preferences?.desktop?.active_skin || activeSkin;
|
||||||
|
const nextUrl = `${window.location.pathname}?skin=${encodeURIComponent(nextSkin)}`;
|
||||||
|
window.location.assign(nextUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
renderWidgets();
|
renderWidgets();
|
||||||
renderStartMenu();
|
renderStartMenu();
|
||||||
renderTraySkins();
|
|
||||||
setStartMenuOpen(false);
|
setStartMenuOpen(false);
|
||||||
measureDesktopBounds();
|
measureDesktopBounds();
|
||||||
applyStoredIconLayout();
|
applyStoredIconLayout();
|
||||||
@@ -1270,4 +1352,14 @@ if (payloadNode) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.refreshDesktopIconContrast = applyDesktopIconContrast;
|
window.refreshDesktopIconContrast = applyDesktopIconContrast;
|
||||||
|
window.desktopShell = {
|
||||||
|
openAppById: (appId) => {
|
||||||
|
const app = findAppById(appId);
|
||||||
|
if (app) {
|
||||||
|
openApp(app);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
persistSettingsPatch,
|
||||||
|
dispatchUserSettingsUpdate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App;
|
|||||||
|
|
||||||
use Desktop\AppRegistry;
|
use Desktop\AppRegistry;
|
||||||
use Desktop\AppIconResolver;
|
use Desktop\AppIconResolver;
|
||||||
|
use Desktop\AppVisibility;
|
||||||
use Desktop\DesktopState;
|
use Desktop\DesktopState;
|
||||||
use Desktop\SkinResolver;
|
use Desktop\SkinResolver;
|
||||||
use Desktop\WidgetRegistry;
|
use Desktop\WidgetRegistry;
|
||||||
@@ -28,16 +29,30 @@ final class App
|
|||||||
$registry = new AppRegistry($this->projectRoot . '/config/apps.php');
|
$registry = new AppRegistry($this->projectRoot . '/config/apps.php');
|
||||||
$widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php');
|
$widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php');
|
||||||
$skins = SkinResolver::all();
|
$skins = SkinResolver::all();
|
||||||
$activeSkin = SkinResolver::resolve($_GET['skin'] ?? null);
|
|
||||||
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
|
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
|
||||||
$authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null)
|
$authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null)
|
||||||
? $_SESSION['desktop_auth']['user']
|
? $_SESSION['desktop_auth']['user']
|
||||||
: [];
|
: [];
|
||||||
$authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? [])));
|
$authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? [])));
|
||||||
$visibleApps = $this->filterAppsForGroups($registry->all(), $authGroups);
|
$visibleApps = AppVisibility::filterByGroups($registry->all(), $authGroups);
|
||||||
$apps = AppIconResolver::decorate($visibleApps, $activeSkin);
|
$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();
|
$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');
|
$displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast');
|
||||||
$storageScope = $isAuthenticated
|
$storageScope = $isAuthenticated
|
||||||
? strtolower((string) ($authUser['username'] ?? $authUser['sub'] ?? 'user'))
|
? strtolower((string) ($authUser['username'] ?? $authUser['sub'] ?? 'user'))
|
||||||
@@ -56,6 +71,8 @@ final class App
|
|||||||
'wallpaper' => SkinResolver::wallpaper($activeSkin),
|
'wallpaper' => SkinResolver::wallpaper($activeSkin),
|
||||||
'storage_scope' => preg_replace('/[^a-z0-9._-]+/i', '-', $storageScope) ?: 'guest',
|
'storage_scope' => preg_replace('/[^a-z0-9._-]+/i', '-', $storageScope) ?: 'guest',
|
||||||
'workspace' => DesktopState::defaultWorkspace(),
|
'workspace' => DesktopState::defaultWorkspace(),
|
||||||
|
'settings_api' => '/api/user-self-management/index.php',
|
||||||
|
'user_preferences' => $userSettings,
|
||||||
'login' => [
|
'login' => [
|
||||||
'authenticated' => $isAuthenticated,
|
'authenticated' => $isAuthenticated,
|
||||||
'mode' => $isAuthenticated ? 'authenticated' : 'login-required',
|
'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;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
314
src/App/UserSelfManagementService.php
Normal file
314
src/App/UserSelfManagementService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ final class AppIconResolver
|
|||||||
'public-dashboard' => '/assets/desktop/skin-icons/windows/public-dashboard.ico',
|
'public-dashboard' => '/assets/desktop/skin-icons/windows/public-dashboard.ico',
|
||||||
'workspace-manager' => '/assets/desktop/skin-icons/windows/workspace-manager.ico',
|
'workspace-manager' => '/assets/desktop/skin-icons/windows/workspace-manager.ico',
|
||||||
'admin-apps' => '/assets/desktop/skin-icons/windows/admin-apps.ico',
|
'admin-apps' => '/assets/desktop/skin-icons/windows/admin-apps.ico',
|
||||||
|
'user-self-management' => '/assets/desktop/skin-icons/windows/admin-apps.ico',
|
||||||
'desktop-login' => '/assets/desktop/skin-icons/windows/desktop-login.ico',
|
'desktop-login' => '/assets/desktop/skin-icons/windows/desktop-login.ico',
|
||||||
'mining-checker' => '/assets/desktop/skin-icons/windows/admin-apps.ico',
|
'mining-checker' => '/assets/desktop/skin-icons/windows/admin-apps.ico',
|
||||||
],
|
],
|
||||||
@@ -48,6 +49,7 @@ final class AppIconResolver
|
|||||||
'public-dashboard' => '/assets/desktop/skin-icons/apple/public-dashboard.svg',
|
'public-dashboard' => '/assets/desktop/skin-icons/apple/public-dashboard.svg',
|
||||||
'workspace-manager' => '/assets/desktop/skin-icons/apple/workspace-manager.svg',
|
'workspace-manager' => '/assets/desktop/skin-icons/apple/workspace-manager.svg',
|
||||||
'admin-apps' => '/assets/desktop/skin-icons/apple/admin-apps.svg',
|
'admin-apps' => '/assets/desktop/skin-icons/apple/admin-apps.svg',
|
||||||
|
'user-self-management' => '/assets/desktop/skin-icons/apple/admin-apps.svg',
|
||||||
'desktop-login' => '/assets/desktop/skin-icons/apple/desktop-login.svg',
|
'desktop-login' => '/assets/desktop/skin-icons/apple/desktop-login.svg',
|
||||||
'mining-checker' => '/assets/desktop/skin-icons/apple/admin-apps.svg',
|
'mining-checker' => '/assets/desktop/skin-icons/apple/admin-apps.svg',
|
||||||
],
|
],
|
||||||
@@ -56,6 +58,7 @@ final class AppIconResolver
|
|||||||
'public-dashboard' => '/assets/desktop/skin-icons/linux/public-dashboard.svg',
|
'public-dashboard' => '/assets/desktop/skin-icons/linux/public-dashboard.svg',
|
||||||
'workspace-manager' => '/assets/desktop/skin-icons/linux/workspace-manager.svg',
|
'workspace-manager' => '/assets/desktop/skin-icons/linux/workspace-manager.svg',
|
||||||
'admin-apps' => '/assets/desktop/skin-icons/linux/admin-apps.svg',
|
'admin-apps' => '/assets/desktop/skin-icons/linux/admin-apps.svg',
|
||||||
|
'user-self-management' => '/assets/desktop/skin-icons/linux/admin-apps.svg',
|
||||||
'desktop-login' => '/assets/desktop/skin-icons/linux/desktop-login.svg',
|
'desktop-login' => '/assets/desktop/skin-icons/linux/desktop-login.svg',
|
||||||
'mining-checker' => '/assets/desktop/skin-icons/linux/admin-apps.svg',
|
'mining-checker' => '/assets/desktop/skin-icons/linux/admin-apps.svg',
|
||||||
],
|
],
|
||||||
|
|||||||
59
src/Desktop/AppVisibility.php
Normal file
59
src/Desktop/AppVisibility.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Desktop;
|
||||||
|
|
||||||
|
final class AppVisibility
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $apps
|
||||||
|
* @param array<int, string> $groups
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function filterByGroups(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;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $apps
|
||||||
|
* @param array<int, string> $enabledAppIds
|
||||||
|
* @param array<int, string> $mandatoryAppIds
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function filterByEnabledIds(array $apps, array $enabledAppIds, array $mandatoryAppIds = []): array
|
||||||
|
{
|
||||||
|
$lookup = array_fill_keys($enabledAppIds, true);
|
||||||
|
$mandatoryLookup = array_fill_keys($mandatoryAppIds, true);
|
||||||
|
|
||||||
|
return array_values(array_filter($apps, static function (array $app) use ($lookup, $mandatoryLookup): bool {
|
||||||
|
$appId = (string) ($app['app_id'] ?? '');
|
||||||
|
|
||||||
|
if ($appId === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($lookup[$appId]) || isset($mandatoryLookup[$appId]);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ final class DesktopState
|
|||||||
public static function quickActions(): array
|
public static function quickActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
[
|
||||||
|
'action_id' => 'open-user-self-management',
|
||||||
|
'label' => 'Setup',
|
||||||
|
'description' => 'Oeffnet die persoenlichen Desktop-Einstellungen.',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'action_id' => 'open-public-dashboard',
|
'action_id' => 'open-public-dashboard',
|
||||||
'label' => 'Public Dashboard',
|
'label' => 'Public Dashboard',
|
||||||
@@ -55,11 +60,6 @@ final class DesktopState
|
|||||||
'label' => 'Widgets umschalten',
|
'label' => 'Widgets umschalten',
|
||||||
'description' => 'Blendet alle registrierten Widgets ein oder aus.',
|
'description' => 'Blendet alle registrierten Widgets ein oder aus.',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'action_id' => 'cycle-skin',
|
|
||||||
'label' => 'Skin wechseln',
|
|
||||||
'description' => 'Wechselt zyklisch zwischen Windows, Apple und Linux.',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user