Rebuild
This commit is contained in:
@@ -10,3 +10,16 @@
|
|||||||
define('APP_BASE_DB_ENABLED', true);
|
define('APP_BASE_DB_ENABLED', true);
|
||||||
define('APP_BASIC_AUTH', false);
|
define('APP_BASIC_AUTH', false);
|
||||||
define('APP_SEARCH_DEBUG', false);
|
define('APP_SEARCH_DEBUG', false);
|
||||||
|
define('APP_AUTH_ENABLED', true);
|
||||||
|
define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin');
|
||||||
|
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/auth');
|
||||||
|
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/token');
|
||||||
|
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/userinfo');
|
||||||
|
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout');
|
||||||
|
define('APP_OIDC_CLIENT_ID', 'nexus');
|
||||||
|
define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs');
|
||||||
|
define('APP_OIDC_REDIRECT_URI', 'https://nexus.int.kusche.berlin/auth/callback');
|
||||||
|
define('APP_OIDC_GROUP_CLAIM', 'groups');
|
||||||
|
define('APP_OIDC_ADMIN_GROUP', 'admin');
|
||||||
|
define('APP_OIDC_USER_GROUP', 'family');
|
||||||
|
define('APP_DEBUG_TOOL', false);
|
||||||
|
|||||||
@@ -10,3 +10,16 @@
|
|||||||
define('APP_BASE_DB_ENABLED', true);
|
define('APP_BASE_DB_ENABLED', true);
|
||||||
define('APP_BASIC_AUTH', true);
|
define('APP_BASIC_AUTH', true);
|
||||||
define('APP_SEARCH_DEBUG', true);
|
define('APP_SEARCH_DEBUG', true);
|
||||||
|
define('APP_AUTH_ENABLED', true);
|
||||||
|
define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin');
|
||||||
|
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/auth');
|
||||||
|
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/token');
|
||||||
|
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/userinfo');
|
||||||
|
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout');
|
||||||
|
define('APP_OIDC_CLIENT_ID', 'nexus');
|
||||||
|
define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs');
|
||||||
|
define('APP_OIDC_REDIRECT_URI', 'https://staging.nexus.int.kusche.berlin/auth/callback');
|
||||||
|
define('APP_OIDC_GROUP_CLAIM', 'groups');
|
||||||
|
define('APP_OIDC_ADMIN_GROUP', 'admin');
|
||||||
|
define('APP_OIDC_USER_GROUP', 'family');
|
||||||
|
define('APP_DEBUG_TOOL', true);
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
"title": "KEA DHCP",
|
"title": "KEA DHCP",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||||
|
"menu": [
|
||||||
|
{ "label": "Hosts", "href": "/module/kea" },
|
||||||
|
{ "label": "Setup", "href": "/modules/setup/kea" }
|
||||||
|
],
|
||||||
"setup": {
|
"setup": {
|
||||||
"fields": [
|
"fields": [
|
||||||
|
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": true },
|
||||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": true },
|
{ "name": "db.host", "label": "DB Host", "type": "text", "required": true },
|
||||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": true },
|
{ "name": "db.port", "label": "DB Port", "type": "number", "required": true },
|
||||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": true },
|
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": true },
|
||||||
@@ -11,6 +16,8 @@
|
|||||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": true },
|
{ "name": "db.user", "label": "DB User", "type": "text", "required": true },
|
||||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": true },
|
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": true },
|
||||||
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
|
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
|
||||||
|
{ "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false },
|
||||||
|
{ "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false },
|
||||||
{ "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false }
|
{ "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="footer-right">Security first · Internal only</div>
|
<div class="footer-right">Security first · Internal only</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<?php asset_scripts('footer'); ?>
|
<?php asset_scripts('footer'); ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
$app = app();
|
$app = app();
|
||||||
$app->assets()->addStyle('/assets/css/app.css', 'early');
|
$app->assets()->addStyle('/assets/css/app.css', 'early');
|
||||||
$app->assets()->addScript('/assets/js/app.js', 'footer', true);
|
$app->assets()->addScript('/assets/js/app.js', 'footer', true);
|
||||||
|
$theme = user_theme();
|
||||||
|
$currentModule = current_module_name();
|
||||||
|
$path = $app->request()->path();
|
||||||
|
$moduleMenu = [];
|
||||||
|
if ($currentModule) {
|
||||||
|
$module = modules()->get($currentModule);
|
||||||
|
$moduleMenu = $module['menu'] ?? [];
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -13,11 +21,11 @@ $app->assets()->addScript('/assets/js/app.js', 'footer', true);
|
|||||||
<?php asset_styles(); ?>
|
<?php asset_styles(); ?>
|
||||||
<?php asset_scripts('header'); ?>
|
<?php asset_scripts('header'); ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-theme="<?= e($theme) ?>">
|
||||||
<div class="bg-orb orb-a"></div>
|
<div class="bg-orb orb-a"></div>
|
||||||
<div class="bg-orb orb-b"></div>
|
<div class="bg-orb orb-b"></div>
|
||||||
<div class="site-shell">
|
<div class="app-shell">
|
||||||
<header class="site-header">
|
<aside class="app-sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/assets/images/logo.png" alt="Nexus Logo">
|
<img src="/assets/images/logo.png" alt="Nexus Logo">
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
@@ -25,13 +33,63 @@ $app->assets()->addScript('/assets/js/app.js', 'footer', true);
|
|||||||
<div class="brand-sub">Nexus Control Panel</div>
|
<div class="brand-sub">Nexus Control Panel</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="site-nav">
|
|
||||||
<a href="/" class="nav-link is-active">Dashboard</a>
|
<button class="sidebar-toggle" data-sidebar-toggle>
|
||||||
<a href="/module/kea" class="nav-link">KEA DHCP</a>
|
☰
|
||||||
<a href="/modules" class="nav-link">Module</a>
|
</button>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="/" class="nav-link <?= $path === '/' ? 'is-active' : '' ?>">Dashboard</a>
|
||||||
|
<div class="nav-section">Aktive Module</div>
|
||||||
|
<?php foreach (modules()->all() as $m): ?>
|
||||||
|
<?php if (!empty($m['enabled'])): ?>
|
||||||
|
<?php $active = $currentModule === $m['name']; ?>
|
||||||
|
<a href="/module/<?= e($m['name']) ?>" class="nav-link <?= $active ? 'is-active' : '' ?>"><?= e($m['title']) ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<a href="/modules" class="nav-link <?= str_starts_with($path, '/modules') ? 'is-active' : '' ?>">Module</a>
|
||||||
|
<a href="/users" class="nav-link <?= $path === '/users' ? 'is-active' : '' ?>">User</a>
|
||||||
|
<a href="/settings" class="nav-link <?= $path === '/settings' ? 'is-active' : '' ?>">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-cta">
|
</aside>
|
||||||
<button class="cta-button">+ Neuer Host</button>
|
|
||||||
|
<div class="app-content">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<?php if ($currentModule && !empty($module['title'])): ?>
|
||||||
|
<h1 class="page-title"><?= e($module['title']) ?></h1>
|
||||||
|
<span class="pill">Modul</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<h1 class="page-title"><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></h1>
|
||||||
|
<span class="pill">Dashboard</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<?php if (auth_enabled() && auth_user()): ?>
|
||||||
|
<span class="pill"><?= e(auth_user()['email'] ?? '') ?></span>
|
||||||
|
<a class="nav-link" href="/auth/logout">Logout</a>
|
||||||
|
<?php elseif (auth_enabled()): ?>
|
||||||
|
<a class="nav-link" href="/auth/login">Login</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="nav-link" href="/users">User</a>
|
||||||
|
<a class="nav-link" href="/modules">Module</a>
|
||||||
|
<a class="nav-link" href="/modules/install">Modul installieren/aktivieren</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<?php if ($moduleMenu): ?>
|
||||||
|
<div class="module-subnav">
|
||||||
|
<?php foreach ($moduleMenu as $entry): ?>
|
||||||
|
<a class="nav-link" href="<?= e($entry['href'] ?? '#') ?>">
|
||||||
|
<?= e($entry['label'] ?? 'Link') ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<main class="site-main">
|
<main class="site-main">
|
||||||
|
<?php if (defined('APP_DEBUG_TOOL') && APP_DEBUG_TOOL): ?>
|
||||||
|
<a class="debug-fab" href="/debug" title="Debug">
|
||||||
|
🐞
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -1,15 +1,51 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0d12;
|
--bg: #f4f6fb;
|
||||||
--panel: #121622;
|
--panel: #ffffff;
|
||||||
--panel-2: #171c2b;
|
--panel-2: #f0f3fb;
|
||||||
--text: #f4f6ff;
|
--text: #151a2d;
|
||||||
--muted: #b6bdd6;
|
--muted: #5a6685;
|
||||||
--accent: #ff5e5b;
|
--accent: #ff6b4a;
|
||||||
--accent-2: #20e3b2;
|
--accent-2: #0fb4a4;
|
||||||
--line: #2a3147;
|
--line: #d7dceb;
|
||||||
--shadow: 0 30px 80px rgba(8, 10, 18, 0.45);
|
--shadow: 0 20px 50px rgba(22, 32, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] {
|
||||||
|
--bg: #f4f6fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-2: #f0f3fb;
|
||||||
|
--text: #151a2d;
|
||||||
|
--muted: #5a6685;
|
||||||
|
--accent: #ff6b4a;
|
||||||
|
--accent-2: #0fb4a4;
|
||||||
|
--line: #d7dceb;
|
||||||
|
--shadow: 0 20px 50px rgba(22, 32, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="ocean"] {
|
||||||
|
--bg: #eef6ff;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-2: #e6f0ff;
|
||||||
|
--text: #0b1b33;
|
||||||
|
--muted: #3a4c6e;
|
||||||
|
--accent: #2d7bff;
|
||||||
|
--accent-2: #00b6b2;
|
||||||
|
--line: #c9d9f3;
|
||||||
|
--shadow: 0 20px 50px rgba(22, 32, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="graphite"] {
|
||||||
|
--bg: #f7f7f8;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-2: #eceff3;
|
||||||
|
--text: #1e222a;
|
||||||
|
--muted: #5c667a;
|
||||||
|
--accent: #ff7a00;
|
||||||
|
--accent-2: #6b7bff;
|
||||||
|
--line: #d5d8df;
|
||||||
|
--shadow: 0 20px 50px rgba(20, 24, 34, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -17,8 +53,8 @@ html, body { height: 100%; }
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
background: radial-gradient(1200px 500px at 10% -10%, rgba(255, 94, 91, 0.2), transparent 60%),
|
background: radial-gradient(1200px 500px at 10% -10%, rgba(255, 107, 74, 0.12), transparent 60%),
|
||||||
radial-gradient(900px 500px at 90% 10%, rgba(32, 227, 178, 0.18), transparent 55%),
|
radial-gradient(900px 500px at 90% 10%, rgba(15, 180, 164, 0.12), transparent 55%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -36,26 +72,50 @@ body {
|
|||||||
.orb-a { top: -120px; left: -80px; background: #ff5e5b; }
|
.orb-a { top: -120px; left: -80px; background: #ff5e5b; }
|
||||||
.orb-b { bottom: -160px; right: -120px; background: #20e3b2; }
|
.orb-b { bottom: -160px; right: -120px; background: #20e3b2; }
|
||||||
|
|
||||||
.site-shell {
|
.app-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 260px 1fr;
|
||||||
padding: 32px 28px 24px;
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.app-sidebar {
|
||||||
display: grid;
|
background: var(--panel);
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: center;
|
|
||||||
background: linear-gradient(140deg, rgba(18, 22, 34, 0.9), rgba(23, 28, 43, 0.9));
|
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 20px 24px;
|
padding: 20px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
backdrop-filter: blur(6px);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .app-shell {
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .app-sidebar .brand-text,
|
||||||
|
.sidebar-collapsed .app-sidebar .nav-section,
|
||||||
|
.sidebar-collapsed .app-sidebar .nav-link {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .app-sidebar .brand img {
|
||||||
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -78,35 +138,41 @@ body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav {
|
.sidebar-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.nav-section {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.nav-link {
|
.nav-link {
|
||||||
color: var(--muted);
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
transition: all 180ms ease;
|
transition: all 180ms ease;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--panel-2);
|
||||||
}
|
}
|
||||||
.nav-link.is-active {
|
.nav-link.is-active {
|
||||||
color: var(--bg);
|
color: #ffffff;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-cta .cta-button {
|
.cta-button {
|
||||||
background: linear-gradient(120deg, var(--accent), #ff9f45);
|
background: linear-gradient(120deg, var(--accent), #ff9f45);
|
||||||
border: none;
|
border: none;
|
||||||
color: #0b0d12;
|
color: #ffffff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -114,9 +180,66 @@ body {
|
|||||||
box-shadow: 0 14px 30px rgba(255, 94, 91, 0.35);
|
box-shadow: 0 14px 30px rgba(255, 94, 91, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-subnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 22px;
|
||||||
|
box-shadow: 0 16px 32px rgba(255, 107, 74, 0.28);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.debug-fab:hover {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.site-main {
|
.site-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-top: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
@@ -136,7 +259,15 @@ body {
|
|||||||
}
|
}
|
||||||
.card input,
|
.card input,
|
||||||
.card textarea {
|
.card textarea {
|
||||||
background: #0f121b;
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.card select {
|
||||||
|
background: #ffffff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -167,17 +298,13 @@ body {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1100px) {
|
||||||
.site-header {
|
.app-shell { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
.app-sidebar { position: relative; top: 0; }
|
||||||
}
|
|
||||||
.header-cta {
|
|
||||||
justify-self: start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
.site-shell { padding: 24px 18px 20px; }
|
.app-shell { padding: 18px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Minimal Tailwind-like utility support for existing templates */
|
/* Minimal Tailwind-like utility support for existing templates */
|
||||||
@@ -194,13 +321,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-white { color: #ffffff; }
|
.text-white { color: #ffffff; }
|
||||||
.text-gray-200 { color: #d9deee; }
|
.text-gray-200 { color: #3a4c6e; }
|
||||||
.text-gray-300 { color: #c2c9de; }
|
.text-gray-300 { color: #4b5775; }
|
||||||
.text-gray-400 { color: #a9b1c9; }
|
.text-gray-400 { color: #5a6685; }
|
||||||
.text-gray-500 { color: #8b94ad; }
|
.text-gray-500 { color: #6b7696; }
|
||||||
.text-red-100 { color: #ffe2e2; }
|
.text-red-100 { color: #8b1d1d; }
|
||||||
.text-indigo-400 { color: #9aa5ff; }
|
.text-indigo-400 { color: #2d7bff; }
|
||||||
.hover\\:text-indigo-300:hover { color: #b7c0ff; }
|
.hover\\:text-indigo-300:hover { color: #1b63da; }
|
||||||
.font-medium { font-weight: 600; }
|
.font-medium { font-weight: 600; }
|
||||||
.font-semibold { font-weight: 700; }
|
.font-semibold { font-weight: 700; }
|
||||||
.font-bold { font-weight: 700; }
|
.font-bold { font-weight: 700; }
|
||||||
@@ -219,8 +346,8 @@ body {
|
|||||||
.border-red-500 { border-color: #ff5e5b; }
|
.border-red-500 { border-color: #ff5e5b; }
|
||||||
.border-gray-700 { border-color: var(--line); }
|
.border-gray-700 { border-color: var(--line); }
|
||||||
.bg-gray-800 { background: var(--panel); }
|
.bg-gray-800 { background: var(--panel); }
|
||||||
.bg-gray-900 { background: #0f121b; }
|
.bg-gray-900 { background: var(--panel-2); }
|
||||||
.bg-red-900 { background: #2c1214; }
|
.bg-red-900 { background: #ffe9e9; }
|
||||||
.shadow { box-shadow: var(--shadow); }
|
.shadow { box-shadow: var(--shadow); }
|
||||||
.overflow-hidden { overflow: hidden; }
|
.overflow-hidden { overflow: hidden; }
|
||||||
.overflow-x-auto { overflow-x: auto; }
|
.overflow-x-auto { overflow-x: auto; }
|
||||||
|
|||||||
@@ -1 +1,13 @@
|
|||||||
console.log('mini example loaded');
|
(() => {
|
||||||
|
const toggle = document.querySelector('[data-sidebar-toggle]');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
document.body.classList.toggle('sidebar-collapsed');
|
||||||
|
localStorage.setItem('sidebar-collapsed', document.body.classList.contains('sidebar-collapsed') ? '1' : '0');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('sidebar-collapsed') === '1') {
|
||||||
|
document.body.classList.add('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ if (defined('APP_BASIC_AUTH') && APP_BASIC_AUTH && !$isRetoolPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC Auth
|
||||||
|
$publicPaths = [
|
||||||
|
'auth/login',
|
||||||
|
'auth/callback',
|
||||||
|
'auth/logout',
|
||||||
|
];
|
||||||
|
if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && !in_array($uriPath, $publicPaths, true)) {
|
||||||
|
$user = auth_user();
|
||||||
|
if (!$user) {
|
||||||
|
header('Location: /auth/login', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sicherheitscheck
|
// Sicherheitscheck
|
||||||
if (str_contains($uriPath, '..')) {
|
if (str_contains($uriPath, '..')) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -29,9 +43,23 @@ if (str_contains($uriPath, '..')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spezialrouten für Module
|
// Spezialrouten für Module
|
||||||
if (str_starts_with($uriPath, 'modules/setup/')) {
|
if (str_starts_with($uriPath, 'modules/install')) {
|
||||||
|
$target = __DIR__ . '/page/modules_install.php';
|
||||||
|
} elseif (str_starts_with($uriPath, 'modules/setup/')) {
|
||||||
$_GET['module'] = trim(substr($uriPath, strlen('modules/setup/')), '/');
|
$_GET['module'] = trim(substr($uriPath, strlen('modules/setup/')), '/');
|
||||||
$target = __DIR__ . '/page/modules_setup.php';
|
$target = __DIR__ . '/page/modules_setup.php';
|
||||||
|
} elseif ($uriPath === 'auth/login') {
|
||||||
|
$target = __DIR__ . '/page/auth_login.php';
|
||||||
|
} elseif ($uriPath === 'auth/callback') {
|
||||||
|
$target = __DIR__ . '/page/auth_callback.php';
|
||||||
|
} elseif ($uriPath === 'auth/logout') {
|
||||||
|
$target = __DIR__ . '/page/auth_logout.php';
|
||||||
|
} elseif ($uriPath === 'settings') {
|
||||||
|
$target = __DIR__ . '/page/settings.php';
|
||||||
|
} elseif ($uriPath === 'users') {
|
||||||
|
$target = __DIR__ . '/page/users.php';
|
||||||
|
} elseif ($uriPath === 'debug') {
|
||||||
|
$target = __DIR__ . '/page/debug.php';
|
||||||
} elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
|
} elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
|
||||||
$module = $m[1];
|
$module = $m[1];
|
||||||
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
|
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
|
||||||
|
|||||||
11
public/page/404.php
Normal file
11
public/page/404.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
http_response_code(404);
|
||||||
|
?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="pill">404</div>
|
||||||
|
<h1 style="margin-top:.75rem;">Seite nicht gefunden</h1>
|
||||||
|
<p class="muted">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<a class="nav-link" href="/">Zur Startseite</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
49
public/page/auth_callback.php
Normal file
49
public/page/auth_callback.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
use App\OidcClient;
|
||||||
|
|
||||||
|
$config = app()->config();
|
||||||
|
$session = app()->session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
if (!$config->authEnabled) {
|
||||||
|
echo '<div class="card">Auth ist deaktiviert.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (string)($_GET['code'] ?? '');
|
||||||
|
$state = (string)($_GET['state'] ?? '');
|
||||||
|
$expectedState = (string)($_SESSION['oidc_state'] ?? '');
|
||||||
|
$nonce = (string)($_SESSION['oidc_nonce'] ?? '');
|
||||||
|
|
||||||
|
if ($code === '' || $state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
|
||||||
|
echo '<div class="card">Ungültiger Login-Status.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($_SESSION['oidc_state']);
|
||||||
|
|
||||||
|
$client = new OidcClient($config);
|
||||||
|
$token = $client->exchangeCode($code);
|
||||||
|
|
||||||
|
$idToken = (string)($token['id_token'] ?? '');
|
||||||
|
if ($idToken === '') {
|
||||||
|
echo '<div class="card">Kein ID Token erhalten.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$claims = $client->decodeJwt($idToken);
|
||||||
|
$client->validateIdToken($claims, $nonce);
|
||||||
|
unset($_SESSION['oidc_nonce']);
|
||||||
|
|
||||||
|
$groups = $client->groupsFromClaims($claims);
|
||||||
|
$user = [
|
||||||
|
'sub' => (string)($claims['sub'] ?? ''),
|
||||||
|
'email' => (string)($claims['email'] ?? ''),
|
||||||
|
'name' => (string)($claims['name'] ?? ($claims['preferred_username'] ?? '')),
|
||||||
|
'groups' => $groups,
|
||||||
|
'id_token' => $idToken,
|
||||||
|
];
|
||||||
|
|
||||||
|
$_SESSION['auth_user'] = $user;
|
||||||
|
|
||||||
|
redirect('/');
|
||||||
19
public/page/auth_login.php
Normal file
19
public/page/auth_login.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
use App\OidcClient;
|
||||||
|
|
||||||
|
$config = app()->config();
|
||||||
|
if (!$config->authEnabled) {
|
||||||
|
echo '<div class="card">Auth ist deaktiviert.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = app()->session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
$state = bin2hex(random_bytes(16));
|
||||||
|
$nonce = bin2hex(random_bytes(16));
|
||||||
|
$_SESSION['oidc_state'] = $state;
|
||||||
|
$_SESSION['oidc_nonce'] = $nonce;
|
||||||
|
|
||||||
|
$client = new OidcClient($config);
|
||||||
|
redirect($client->authUrl($state, $nonce));
|
||||||
23
public/page/auth_logout.php
Normal file
23
public/page/auth_logout.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
use App\OidcClient;
|
||||||
|
|
||||||
|
$config = app()->config();
|
||||||
|
$session = app()->session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
$idToken = null;
|
||||||
|
if (!empty($_SESSION['auth_user']['id_token'])) {
|
||||||
|
$idToken = (string)$_SESSION['auth_user']['id_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($_SESSION['auth_user']);
|
||||||
|
|
||||||
|
if ($config->authEnabled) {
|
||||||
|
$client = new OidcClient($config);
|
||||||
|
$url = $client->logoutUrl($idToken);
|
||||||
|
if ($url) {
|
||||||
|
redirect($url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/');
|
||||||
90
public/page/debug.php
Normal file
90
public/page/debug.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
if (!defined('APP_DEBUG_TOOL') || !APP_DEBUG_TOOL) {
|
||||||
|
echo '<div class="card">Debug-Tool ist deaktiviert.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$debugDir = __DIR__ . '/../../debug';
|
||||||
|
if (!is_dir($debugDir)) {
|
||||||
|
echo '<div class="card">Debug-Verzeichnis fehlt.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_values(array_filter(scandir($debugDir) ?: [], function ($f) use ($debugDir) {
|
||||||
|
if ($f === '.' || $f === '..') return false;
|
||||||
|
$path = $debugDir . '/' . $f;
|
||||||
|
return is_file($path);
|
||||||
|
}));
|
||||||
|
|
||||||
|
$selected = (string)($_GET['file'] ?? '');
|
||||||
|
$content = null;
|
||||||
|
|
||||||
|
if ($selected !== '' && preg_match('/^[a-zA-Z0-9._-]+$/', $selected)) {
|
||||||
|
$path = $debugDir . '/' . $selected;
|
||||||
|
if (is_file($path)) {
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['raw']) && $_GET['raw'] === '1') {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo $content ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="pill">Debug</div>
|
||||||
|
<h1 style="margin-top:.75rem;">Debug Logs</h1>
|
||||||
|
<p class="muted">Hier kannst du temporäre Log-Files aus dem <code>debug/</code>-Ordner ansehen.</p>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem;" class="grid">
|
||||||
|
<div class="card" style="background:var(--panel-2);">
|
||||||
|
<strong>Logs</strong>
|
||||||
|
<ul style="margin-top:.5rem;">
|
||||||
|
<?php if (!$files): ?>
|
||||||
|
<li class="muted">Keine Logs vorhanden.</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($files as $f): ?>
|
||||||
|
<li>
|
||||||
|
<a class="nav-link" href="/debug?file=<?= e($f) ?>"><?= e($f) ?></a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="background:var(--panel-2);">
|
||||||
|
<strong>Inhalt</strong>
|
||||||
|
<?php if ($content === null): ?>
|
||||||
|
<p class="muted" style="margin-top:.5rem;">Wähle eine Datei.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<pre id="debug-content" style="margin-top:.5rem; white-space:pre-wrap; font-family:monospace;"><?= e($content) ?></pre>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($selected !== ''): ?>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const el = document.getElementById('debug-content');
|
||||||
|
if (!el) return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('raw', '1');
|
||||||
|
let last = '';
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString(), { cache: 'no-store' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const text = await res.text();
|
||||||
|
if (text !== last) {
|
||||||
|
el.textContent = text;
|
||||||
|
last = text;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 3000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -4,6 +4,7 @@ $error = null;
|
|||||||
$notice = null;
|
$notice = null;
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_admin();
|
||||||
$name = (string)($_POST['module'] ?? '');
|
$name = (string)($_POST['module'] ?? '');
|
||||||
$action = (string)($_POST['action'] ?? '');
|
$action = (string)($_POST['action'] ?? '');
|
||||||
|
|
||||||
@@ -18,8 +19,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<?php require_auth(); ?>
|
||||||
<div class="pill">Module</div>
|
<div class="pill">Module</div>
|
||||||
<h1 style="margin-top:.75rem;">Module verwalten</h1>
|
<h1 style="margin-top:.75rem;">Module verwalten</h1>
|
||||||
|
<p class="muted">Hier siehst du nur aktive Module. Installierte Module kannst du unten verwalten.</p>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
|
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
|
||||||
@@ -33,31 +36,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
<div style="margin-top:1rem;" class="grid">
|
<div style="margin-top:1rem;" class="grid">
|
||||||
<?php foreach ($modules as $module): ?>
|
<?php foreach ($modules as $module): ?>
|
||||||
|
<?php if (empty($module['enabled'])) { continue; } ?>
|
||||||
<div class="card" style="background:var(--panel-2);">
|
<div class="card" style="background:var(--panel-2);">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
<strong><?= e($module['title']) ?></strong>
|
<strong><?= e($module['title']) ?></strong>
|
||||||
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($module['enabled'])): ?>
|
|
||||||
<span class="pill" style="border-color:var(--accent-2); color:var(--accent-2);">aktiv</span>
|
<span class="pill" style="border-color:var(--accent-2); color:var(--accent-2);">aktiv</span>
|
||||||
<?php else: ?>
|
|
||||||
<span class="pill">inaktiv</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:.75rem; display:flex; gap:10px; flex-wrap:wrap;">
|
<div style="margin-top:.75rem; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
||||||
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
||||||
<form method="post" style="margin:0;">
|
<form method="post" style="margin:0;">
|
||||||
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
<?php if (!empty($module['enabled'])): ?>
|
|
||||||
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
||||||
<?php else: ?>
|
|
||||||
<button class="cta-button" name="action" value="enable">Aktivieren</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem;">
|
||||||
|
<a class="nav-link" href="/modules/install">Modul installieren/aktivieren</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
78
public/page/modules_install.php
Normal file
78
public/page/modules_install.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
$modules = modules()->all();
|
||||||
|
$error = null;
|
||||||
|
$notice = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_admin();
|
||||||
|
$name = (string)($_POST['module'] ?? '');
|
||||||
|
$action = (string)($_POST['action'] ?? '');
|
||||||
|
if ($name !== '' && ($action === 'enable' || $action === 'disable')) {
|
||||||
|
modules()->setEnabled($name, $action === 'enable');
|
||||||
|
$notice = $action === 'enable' ? 'Modul aktiviert.' : 'Modul deaktiviert.';
|
||||||
|
$modules = modules()->all();
|
||||||
|
} else {
|
||||||
|
$error = 'Ungültige Aktion.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$active = [];
|
||||||
|
$inactive = [];
|
||||||
|
foreach ($modules as $m) {
|
||||||
|
if (!empty($m['enabled'])) {
|
||||||
|
$active[] = $m;
|
||||||
|
} else {
|
||||||
|
$inactive[] = $m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?php require_auth(); ?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="pill">Module</div>
|
||||||
|
<h1 style="margin-top:.75rem;">Module installieren/aktivieren</h1>
|
||||||
|
<p class="muted">Erkannte Module basieren auf Ordnern in <code>modules/</code>.</p>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($notice): ?>
|
||||||
|
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
|
||||||
|
<?= e($notice) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h3 style="margin-top:1.25rem;">Aktive Module</h3>
|
||||||
|
<div style="margin-top:.5rem;" class="grid">
|
||||||
|
<?php foreach ($active as $module): ?>
|
||||||
|
<div class="card" style="background:var(--panel-2);">
|
||||||
|
<strong><?= e($module['title']) ?></strong>
|
||||||
|
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:10px;">
|
||||||
|
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
||||||
|
<form method="post" style="margin:0;">
|
||||||
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
|
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top:1.5rem;">Deaktivierte Module</h3>
|
||||||
|
<div style="margin-top:.5rem;" class="grid">
|
||||||
|
<?php foreach ($inactive as $module): ?>
|
||||||
|
<div class="card" style="background:var(--panel-2);">
|
||||||
|
<strong><?= e($module['title']) ?></strong>
|
||||||
|
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:10px;">
|
||||||
|
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
||||||
|
<form method="post" style="margin:0;">
|
||||||
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
|
<button class="cta-button" name="action" value="enable">Aktivieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,8 @@ $module = modules()->get($moduleName);
|
|||||||
$error = null;
|
$error = null;
|
||||||
$notice = null;
|
$notice = null;
|
||||||
|
|
||||||
|
require_admin();
|
||||||
|
|
||||||
if (!$module) {
|
if (!$module) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo '<div class="card">Modul nicht gefunden.</div>';
|
echo '<div class="card">Modul nicht gefunden.</div>';
|
||||||
|
|||||||
47
public/page/settings.php
Normal file
47
public/page/settings.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
$themes = [
|
||||||
|
'light' => 'Light',
|
||||||
|
'ocean' => 'Ocean',
|
||||||
|
'graphite' => 'Graphite',
|
||||||
|
];
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
$current = user_theme();
|
||||||
|
$notice = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$theme = (string)($_POST['theme'] ?? 'light');
|
||||||
|
if (!isset($themes[$theme])) {
|
||||||
|
$theme = 'light';
|
||||||
|
}
|
||||||
|
set_user_theme($theme);
|
||||||
|
$current = $theme;
|
||||||
|
$notice = 'Theme gespeichert.';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="pill">Einstellungen</div>
|
||||||
|
<h1 style="margin-top:.75rem;">User-Design</h1>
|
||||||
|
<p class="muted">Wähle deine persönliche Farbpalette.</p>
|
||||||
|
|
||||||
|
<?php if ($notice): ?>
|
||||||
|
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
|
||||||
|
<?= e($notice) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" style="margin-top:1rem; display:grid; gap:12px; max-width:360px;">
|
||||||
|
<label class="muted" style="display:grid; gap:6px;">
|
||||||
|
<span>Farbpalette</span>
|
||||||
|
<select name="theme">
|
||||||
|
<?php foreach ($themes as $key => $label): ?>
|
||||||
|
<option value="<?= e($key) ?>" <?= $current === $key ? 'selected' : '' ?>>
|
||||||
|
<?= e($label) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="cta-button" type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
128
public/page/users.php
Normal file
128
public/page/users.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
$pdo = app()->basePdo();
|
||||||
|
$error = null;
|
||||||
|
$notice = null;
|
||||||
|
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
echo '<div class="card">Base-DB nicht aktiviert.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = (string)($_POST['action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'add_role') {
|
||||||
|
$role = trim((string)($_POST['role'] ?? ''));
|
||||||
|
$desc = trim((string)($_POST['description'] ?? ''));
|
||||||
|
if ($role === '') {
|
||||||
|
$error = 'Rollenname fehlt.';
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO nexus_roles (name, description)
|
||||||
|
VALUES (:name, :description)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET description = excluded.description"
|
||||||
|
);
|
||||||
|
$stmt->execute(['name' => $role, 'description' => $desc]);
|
||||||
|
$notice = 'Rolle gespeichert.';
|
||||||
|
}
|
||||||
|
} elseif ($action === 'add_user') {
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
$role = trim((string)($_POST['role'] ?? 'user'));
|
||||||
|
|
||||||
|
if ($email === '' || $password === '') {
|
||||||
|
$error = 'E-Mail und Passwort sind erforderlich.';
|
||||||
|
} else {
|
||||||
|
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO nexus_users (email, password_hash, role, is_active)
|
||||||
|
VALUES (:email, :hash, :role, 1)"
|
||||||
|
)->execute([
|
||||||
|
'email' => $email,
|
||||||
|
'hash' => $hash,
|
||||||
|
'role' => $role !== '' ? $role : 'user',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO nexus_roles (name) VALUES (:name)
|
||||||
|
ON CONFLICT(name) DO NOTHING"
|
||||||
|
)->execute(['name' => $role !== '' ? $role : 'user']);
|
||||||
|
|
||||||
|
$notice = 'User angelegt.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $pdo->query("SELECT name, description FROM nexus_roles ORDER BY name")->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$users = $pdo->query("SELECT id, email, role, is_active, created_at FROM nexus_users ORDER BY id DESC")->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="pill">Userverwaltung</div>
|
||||||
|
<h1 style="margin-top:.75rem;">User & Rollen</h1>
|
||||||
|
<p class="muted">Admin kann Module aktivieren/deaktivieren, Benutzer können Module nutzen.</p>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($notice): ?>
|
||||||
|
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
|
||||||
|
<?= e($notice) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem;" class="grid">
|
||||||
|
<div class="card" style="background:var(--panel-2);">
|
||||||
|
<strong>Rollen</strong>
|
||||||
|
<ul style="margin-top:.5rem;">
|
||||||
|
<?php foreach ($roles as $r): ?>
|
||||||
|
<li><?= e($r['name']) ?> <span class="muted"><?= e($r['description'] ?? '') ?></span></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" style="margin-top:1rem; display:grid; gap:10px;">
|
||||||
|
<input type="hidden" name="action" value="add_role">
|
||||||
|
<input type="text" name="role" placeholder="Rollenname (z. B. admin)">
|
||||||
|
<input type="text" name="description" placeholder="Beschreibung">
|
||||||
|
<button class="cta-button" type="submit">Rolle hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="background:var(--panel-2);">
|
||||||
|
<strong>User anlegen</strong>
|
||||||
|
<form method="post" style="margin-top:1rem; display:grid; gap:10px;">
|
||||||
|
<input type="hidden" name="action" value="add_user">
|
||||||
|
<input type="email" name="email" placeholder="E-Mail">
|
||||||
|
<input type="password" name="password" placeholder="Passwort">
|
||||||
|
<input type="text" name="role" placeholder="Rolle (admin|user|...)">
|
||||||
|
<button class="cta-button" type="submit">User anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top:1.5rem;">Userliste</h3>
|
||||||
|
<div style="margin-top:.5rem; background:var(--panel-2);" class="card">
|
||||||
|
<table class="min-w-full divide-y divide-gray-700">
|
||||||
|
<thead class="bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">E-Mail</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Rolle</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Aktiv</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Erstellt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-gray-800 divide-y divide-gray-700">
|
||||||
|
<?php foreach ($users as $u): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 text-sm"><?= e($u['email']) ?></td>
|
||||||
|
<td class="px-6 py-4 text-sm"><?= e($u['role']) ?></td>
|
||||||
|
<td class="px-6 py-4 text-sm"><?= !empty($u['is_active']) ? 'Ja' : 'Nein' ?></td>
|
||||||
|
<td class="px-6 py-4 text-sm"><?= e((string)$u['created_at']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -58,6 +58,21 @@ final class BaseSchema
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_roles (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
description TEXT
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_user_prefs (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
theme TEXT NOT NULL DEFAULT 'light',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function ensureSqlite(\PDO $pdo): void
|
private static function ensureSqlite(\PDO $pdo): void
|
||||||
@@ -99,6 +114,21 @@ final class BaseSchema
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_roles (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
description TEXT
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_user_prefs (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
theme TEXT NOT NULL DEFAULT 'light',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function ensureGeneric(\PDO $pdo): void
|
private static function ensureGeneric(\PDO $pdo): void
|
||||||
@@ -140,5 +170,20 @@ final class BaseSchema
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_roles (
|
||||||
|
name VARCHAR(64) PRIMARY KEY,
|
||||||
|
description VARCHAR(255)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_user_prefs (
|
||||||
|
client_id VARCHAR(190) PRIMARY KEY,
|
||||||
|
theme VARCHAR(64) NOT NULL DEFAULT 'light',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ class Config
|
|||||||
public string $keaDbVersion;
|
public string $keaDbVersion;
|
||||||
public array $baseDb;
|
public array $baseDb;
|
||||||
public bool $baseDbEnabled;
|
public bool $baseDbEnabled;
|
||||||
|
public bool $authEnabled;
|
||||||
|
public string $oidcIssuer;
|
||||||
|
public string $oidcClientId;
|
||||||
|
public string $oidcClientSecret;
|
||||||
|
public string $oidcRedirectUri;
|
||||||
|
public string $oidcAuthEndpoint;
|
||||||
|
public string $oidcTokenEndpoint;
|
||||||
|
public string $oidcUserinfoEndpoint;
|
||||||
|
public string $oidcLogoutEndpoint;
|
||||||
|
public string $oidcGroupClaim;
|
||||||
|
public string $oidcAdminGroup;
|
||||||
|
public string $oidcUserGroup;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public array $db,
|
public array $db,
|
||||||
@@ -26,6 +38,18 @@ class Config
|
|||||||
$this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : '';
|
$this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : '';
|
||||||
$this->baseDb = $baseDb;
|
$this->baseDb = $baseDb;
|
||||||
$this->baseDbEnabled = $baseDbEnabled;
|
$this->baseDbEnabled = $baseDbEnabled;
|
||||||
|
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : false;
|
||||||
|
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : '';
|
||||||
|
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : '';
|
||||||
|
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : '';
|
||||||
|
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : '';
|
||||||
|
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : '';
|
||||||
|
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : '';
|
||||||
|
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : '';
|
||||||
|
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : '';
|
||||||
|
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : 'groups';
|
||||||
|
$this->oidcAdminGroup = defined('APP_OIDC_ADMIN_GROUP') ? (string)APP_OIDC_ADMIN_GROUP : 'admin';
|
||||||
|
$this->oidcUserGroup = defined('APP_OIDC_USER_GROUP') ? (string)APP_OIDC_USER_GROUP : 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function primaryUrl(): string
|
public function primaryUrl(): string
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ final class ModuleManager
|
|||||||
'version' => $data['version'] ?? '',
|
'version' => $data['version'] ?? '',
|
||||||
'description' => $data['description'] ?? '',
|
'description' => $data['description'] ?? '',
|
||||||
'setup' => $data['setup'] ?? [],
|
'setup' => $data['setup'] ?? [],
|
||||||
|
'menu' => $data['menu'] ?? [],
|
||||||
'db_defaults' => $data['db_defaults'] ?? [],
|
'db_defaults' => $data['db_defaults'] ?? [],
|
||||||
'path' => $dir,
|
'path' => $dir,
|
||||||
'enabled' => false,
|
'enabled' => false,
|
||||||
|
|||||||
153
src/App/OidcClient.php
Normal file
153
src/App/OidcClient.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class OidcClient
|
||||||
|
{
|
||||||
|
public function __construct(private Config $config) {}
|
||||||
|
|
||||||
|
public function authUrl(string $state, string $nonce): string
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'client_id' => $this->config->oidcClientId,
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'openid profile email',
|
||||||
|
'redirect_uri' => $this->config->oidcRedirectUri,
|
||||||
|
'state' => $state,
|
||||||
|
'nonce' => $nonce,
|
||||||
|
];
|
||||||
|
|
||||||
|
$endpoint = $this->endpoint('auth');
|
||||||
|
return $endpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exchangeCode(string $code): array
|
||||||
|
{
|
||||||
|
$endpoint = $this->endpoint('token');
|
||||||
|
$post = [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'code' => $code,
|
||||||
|
'redirect_uri' => $this->config->oidcRedirectUri,
|
||||||
|
'client_id' => $this->config->oidcClientId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->config->oidcClientSecret !== '') {
|
||||||
|
$post['client_secret'] = $this->config->oidcClientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($endpoint);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986),
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new \RuntimeException('OIDC token request failed: ' . curl_error($ch));
|
||||||
|
}
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new \RuntimeException('OIDC token response invalid.');
|
||||||
|
}
|
||||||
|
if ($status >= 400) {
|
||||||
|
$msg = $data['error_description'] ?? $data['error'] ?? 'OIDC token error';
|
||||||
|
throw new \RuntimeException($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decodeJwt(string $jwt): array
|
||||||
|
{
|
||||||
|
$parts = explode('.', $jwt);
|
||||||
|
if (count($parts) < 2) {
|
||||||
|
throw new \RuntimeException('Invalid JWT');
|
||||||
|
}
|
||||||
|
$payload = $this->b64url_decode($parts[1]);
|
||||||
|
$data = json_decode($payload, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new \RuntimeException('Invalid JWT payload');
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateIdToken(array $claims, string $nonce): void
|
||||||
|
{
|
||||||
|
if (!empty($this->config->oidcIssuer) && ($claims['iss'] ?? '') !== $this->config->oidcIssuer) {
|
||||||
|
throw new \RuntimeException('Invalid token issuer');
|
||||||
|
}
|
||||||
|
|
||||||
|
$aud = $claims['aud'] ?? null;
|
||||||
|
if (is_array($aud)) {
|
||||||
|
if (!in_array($this->config->oidcClientId, $aud, true)) {
|
||||||
|
throw new \RuntimeException('Invalid token audience');
|
||||||
|
}
|
||||||
|
} elseif (is_string($aud)) {
|
||||||
|
if ($aud !== $this->config->oidcClientId) {
|
||||||
|
throw new \RuntimeException('Invalid token audience');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($claims['nonce']) && $claims['nonce'] !== $nonce) {
|
||||||
|
throw new \RuntimeException('Invalid token nonce');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($claims['exp']) && time() >= (int)$claims['exp']) {
|
||||||
|
throw new \RuntimeException('Token expired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function groupsFromClaims(array $claims): array
|
||||||
|
{
|
||||||
|
$claim = $this->config->oidcGroupClaim ?: 'groups';
|
||||||
|
$value = $claims[$claim] ?? [];
|
||||||
|
if (is_string($value)) {
|
||||||
|
$value = [$value];
|
||||||
|
}
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return array_values(array_filter(array_map('strval', $value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logoutUrl(?string $idToken): ?string
|
||||||
|
{
|
||||||
|
$endpoint = $this->config->oidcLogoutEndpoint;
|
||||||
|
if ($endpoint === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$params = [
|
||||||
|
'post_logout_redirect_uri' => $this->config->oidcRedirectUri ? dirname($this->config->oidcRedirectUri) : '/',
|
||||||
|
];
|
||||||
|
if ($idToken) {
|
||||||
|
$params['id_token_hint'] = $idToken;
|
||||||
|
}
|
||||||
|
return $endpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function endpoint(string $type): string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'auth' => $this->config->oidcAuthEndpoint !== '' ? $this->config->oidcAuthEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/auth',
|
||||||
|
'token' => $this->config->oidcTokenEndpoint !== '' ? $this->config->oidcTokenEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/token',
|
||||||
|
'userinfo' => $this->config->oidcUserinfoEndpoint !== '' ? $this->config->oidcUserinfoEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/userinfo',
|
||||||
|
default => throw new \RuntimeException('Unknown OIDC endpoint'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function b64url_decode(string $data): string
|
||||||
|
{
|
||||||
|
$data = strtr($data, '-_', '+/');
|
||||||
|
$pad = strlen($data) % 4;
|
||||||
|
if ($pad) {
|
||||||
|
$data .= str_repeat('=', 4 - $pad);
|
||||||
|
}
|
||||||
|
return base64_decode($data) ?: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,111 @@ function t(string $key, $default = '', array $vars = []): string
|
|||||||
return app()->i18n()->get($key, $default, $vars);
|
return app()->i18n()->get($key, $default, $vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function current_client_id(): string
|
||||||
|
{
|
||||||
|
$session = app()->session();
|
||||||
|
$session->start();
|
||||||
|
return $session->ensureClientId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function user_theme(): string
|
||||||
|
{
|
||||||
|
$pdo = app()->basePdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = current_client_id();
|
||||||
|
$stmt = $pdo->prepare("SELECT theme FROM nexus_user_prefs WHERE client_id = :id LIMIT 1");
|
||||||
|
$stmt->execute(['id' => $clientId]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
$theme = is_array($row) ? (string)($row['theme'] ?? '') : '';
|
||||||
|
return $theme !== '' ? $theme : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_user_theme(string $theme): void
|
||||||
|
{
|
||||||
|
$pdo = app()->basePdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = current_client_id();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO nexus_user_prefs (client_id, theme, updated_at)
|
||||||
|
VALUES (:id, :theme, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(client_id) DO UPDATE SET
|
||||||
|
theme = excluded.theme,
|
||||||
|
updated_at = CURRENT_TIMESTAMP"
|
||||||
|
);
|
||||||
|
$stmt->execute(['id' => $clientId, 'theme' => $theme]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function current_module_name(): ?string
|
||||||
|
{
|
||||||
|
$path = app()->request()->path();
|
||||||
|
if (preg_match('~^/module/([a-zA-Z0-9_-]+)~', $path, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_enabled(): bool
|
||||||
|
{
|
||||||
|
return app()->config()->authEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_user(): ?array
|
||||||
|
{
|
||||||
|
$session = app()->session();
|
||||||
|
$session->start();
|
||||||
|
return $_SESSION['auth_user'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_groups(): array
|
||||||
|
{
|
||||||
|
$user = auth_user();
|
||||||
|
return is_array($user['groups'] ?? null) ? $user['groups'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_is_admin(): bool
|
||||||
|
{
|
||||||
|
$config = app()->config();
|
||||||
|
$groups = auth_groups();
|
||||||
|
return in_array($config->oidcAdminGroup, $groups, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_is_user(): bool
|
||||||
|
{
|
||||||
|
$config = app()->config();
|
||||||
|
$groups = auth_groups();
|
||||||
|
if (in_array($config->oidcAdminGroup, $groups, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return in_array($config->oidcUserGroup, $groups, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_auth(): void
|
||||||
|
{
|
||||||
|
if (!auth_enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auth_user()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_admin(): void
|
||||||
|
{
|
||||||
|
require_auth();
|
||||||
|
if (!auth_is_admin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo '<div class="card">Keine Berechtigung.</div>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function modules(): \App\ModuleManager
|
function modules(): \App\ModuleManager
|
||||||
{
|
{
|
||||||
return app()->modules();
|
return app()->modules();
|
||||||
|
|||||||
Reference in New Issue
Block a user