From 0b9014dcbfd92b472ba898ee7d2a4a5d494746b5 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 12 Mar 2026 14:09:06 +0100 Subject: [PATCH] page update --- config/routes.php | 5 +- db/init/init.sql | 57 ++- i18n/default_de.json | 13 + i18n/default_en.json | 13 + lib/Http/AccessControl.php | 8 - lib/Support/helpers/ui.php | 2 +- pages/admin/tenants/_form.phtml | 336 +++++++++++------- pages/page/copy-language().php | 15 +- pages/page/index($slug).php | 7 +- pages/page/index(default).phtml | 113 ++++++ pages/public-page/index($slug).php | 47 +++ pages/{page => public-page}/index(page).phtml | 0 templates/page.phtml | 1 + templates/partials/app-footer.phtml | 11 +- tests/Architecture/PublicPageContractTest.php | 56 +++ tests/Http/AccessControlTest.php | 33 ++ web/css/components/app-page-editor.css | 113 +++--- 17 files changed, 634 insertions(+), 196 deletions(-) create mode 100644 pages/page/index(default).phtml create mode 100644 pages/public-page/index($slug).php rename pages/{page => public-page}/index(page).phtml (100%) create mode 100644 tests/Architecture/PublicPageContractTest.php create mode 100644 tests/Http/AccessControlTest.php diff --git a/config/routes.php b/config/routes.php index f8006ad..b5deae0 100644 --- a/config/routes.php +++ b/config/routes.php @@ -10,6 +10,7 @@ return [ ['path' => 'password/reset', 'target' => 'auth/reset', 'public' => true], ['path' => 'verify-email', 'target' => 'auth/verify-email', 'public' => true], ['path' => 'lang', 'target' => 'lang', 'public' => true], - ['path' => 'imprint', 'target' => 'page/imprint', 'public' => true], - ['path' => 'privacy', 'target' => 'page/privacy', 'public' => true], + ['path' => 'imprint', 'target' => 'public-page/imprint', 'public' => true], + ['path' => 'privacy', 'target' => 'public-page/privacy', 'public' => true], + ['path' => 'terms', 'target' => 'public-page/terms', 'public' => true], ]; diff --git a/db/init/init.sql b/db/init/init.sql index 26ed0f1..0cb40eb 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -875,8 +875,61 @@ WHERE u.email = 'demo@user.com' ); INSERT INTO `pages` (`uuid`, `slug`, `created`) -SELECT UUID(), 'impressum', NOW() -WHERE NOT EXISTS (SELECT 1 FROM pages WHERE slug = 'impressum'); +SELECT UUID(), v.slug, NOW() +FROM ( + SELECT 'imprint' AS slug + UNION ALL SELECT 'privacy' + UNION ALL SELECT 'terms' +) AS v +WHERE NOT EXISTS (SELECT 1 FROM pages p WHERE p.slug = v.slug); + +INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) +SELECT p.id, 'de', '{"blocks":[{"type":"header","data":{"text":"Impressum","level":2}},{"type":"paragraph","data":{"text":"Diese Seite ist eine Vorlage. Bitte tragen Sie hier die Pflichtangaben Ihres Unternehmens ein."}},{"type":"paragraph","data":{"text":"Angaben gemaess Paragraph 5 TMG: Firmenname, Rechtsform, vertretungsberechtigte Person, Anschrift, Kontakt und Registerangaben."}},{"type":"paragraph","data":{"text":"Inhaltlich verantwortlich: Name und Anschrift."}}]}', p.created +FROM pages p +WHERE p.slug = 'imprint' + AND NOT EXISTS ( + SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'de' + ); + +INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) +SELECT p.id, 'en', '{"blocks":[{"type":"header","data":{"text":"Imprint","level":2}},{"type":"paragraph","data":{"text":"This page is a template. Please add your mandatory company details here."}},{"type":"paragraph","data":{"text":"Provide legal publisher information, address, contact channels, company register data and authorized representatives."}},{"type":"paragraph","data":{"text":"Responsible for content: name and address."}}]}', p.created +FROM pages p +WHERE p.slug = 'imprint' + AND NOT EXISTS ( + SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'en' + ); + +INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) +SELECT p.id, 'de', '{"blocks":[{"type":"header","data":{"text":"Datenschutz","level":2}},{"type":"paragraph","data":{"text":"Diese Seite ist eine Vorlage. Beschreiben Sie hier transparent, welche personenbezogenen Daten verarbeitet werden."}},{"type":"paragraph","data":{"text":"Nennen Sie Rechtsgrundlagen, Speicherdauer, Empfaenger, Betroffenenrechte und Kontakt der Datenschutzstelle."}},{"type":"paragraph","data":{"text":"Pruefen Sie die Inhalte regelmaessig mit Ihrem Datenschutzbeauftragten."}}]}', p.created +FROM pages p +WHERE p.slug = 'privacy' + AND NOT EXISTS ( + SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'de' + ); + +INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) +SELECT p.id, 'en', '{"blocks":[{"type":"header","data":{"text":"Privacy","level":2}},{"type":"paragraph","data":{"text":"This page is a template. Explain which personal data is processed and for what purpose."}},{"type":"paragraph","data":{"text":"Document legal bases, retention periods, recipients, data subject rights and your privacy contact point."}},{"type":"paragraph","data":{"text":"Review this content regularly with your legal or privacy team."}}]}', p.created +FROM pages p +WHERE p.slug = 'privacy' + AND NOT EXISTS ( + SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'en' + ); + +INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) +SELECT p.id, 'de', '{"blocks":[{"type":"header","data":{"text":"Nutzungsbedingungen","level":2}},{"type":"paragraph","data":{"text":"Diese Seite ist eine Vorlage. Beschreiben Sie hier Zweck, Leistungsumfang und zulaessige Nutzung Ihres Angebots."}},{"type":"paragraph","data":{"text":"Ergaenzen Sie Regelungen zu Haftung, Laufzeit, Kuendigung, Gerichtsstand und anwendbarem Recht."}},{"type":"paragraph","data":{"text":"Lassen Sie die Inhalte vor Veroeffentlichung rechtlich pruefen."}}]}', p.created +FROM pages p +WHERE p.slug = 'terms' + AND NOT EXISTS ( + SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'de' + ); + +INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) +SELECT p.id, 'en', '{"blocks":[{"type":"header","data":{"text":"Terms of Service","level":2}},{"type":"paragraph","data":{"text":"This page is a template. Describe the service scope, intended use and user obligations."}},{"type":"paragraph","data":{"text":"Add clauses for liability, term, termination, governing law and jurisdiction."}},{"type":"paragraph","data":{"text":"Have this content reviewed by legal counsel before publishing."}}]}', p.created +FROM pages p +WHERE p.slug = 'terms' + AND NOT EXISTS ( + SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'en' + ); INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`) SELECT p.id, 'de', NULL, p.created diff --git a/i18n/default_de.json b/i18n/default_de.json index 570d97d..760afeb 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -752,6 +752,7 @@ "Unverified": "Nicht verifiziert", "Imprint": "Impressum", "Privacy": "Datenschutz", + "Terms of Service": "Nutzungsbedingungen", "Custom fields": "Zusatzfelder", "User custom fields": "Benutzerzusatzfelder", "Affected tenants": "Betroffene Mandanten", @@ -1271,5 +1272,17 @@ "Force on": "Erzwingen (an)", "Force off": "Erzwingen (aus)", "Controls whether Microsoft logins automatically persist a remember-me token.": "Steuert, ob Microsoft-Anmeldungen automatisch ein Angemeldet-bleiben-Token setzen.", + "Complete": "Vollständig", + "Incomplete": "Unvollständig", + "No domain restrictions": "Keine Domain-Einschränkungen", + "Configuration status": "Konfigurationsstatus", + "SSO setup is incomplete": "SSO-Konfiguration ist unvollständig", + "Complete the required Microsoft configuration before enforcing Microsoft-only login.": "Vervollständigen Sie die erforderliche Microsoft-Konfiguration, bevor Sie Microsoft-only-Anmeldung erzwingen.", + "Login policy": "Anmelderichtlinie", + "Allow Microsoft-only login (disable local password login)": "Nur Microsoft-Anmeldung erlauben (lokale Passwort-Anmeldung deaktivieren)", + "Remember login after Microsoft sign-in": "Angemeldet bleiben nach Microsoft-Login", + "Access and profile sync": "Zugriff und Profilabgleich", + "Profile sync": "Profilabgleich", + "App credentials (advanced)": "App-Anmeldedaten (Erweitert)", "Remember token lifetime is invalid": "Token-Lebensdauer ist ungültig" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 5ba185b..8680d59 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -752,6 +752,7 @@ "Unverified": "Unverified", "Imprint": "Imprint", "Privacy": "Privacy", + "Terms of Service": "Terms of Service", "Custom fields": "Custom fields", "User custom fields": "User custom fields", "Affected tenants": "Affected tenants", @@ -1271,5 +1272,17 @@ "Force on": "Force on", "Force off": "Force off", "Controls whether Microsoft logins automatically persist a remember-me token.": "Controls whether Microsoft logins automatically persist a remember-me token.", + "Complete": "Complete", + "Incomplete": "Incomplete", + "No domain restrictions": "No domain restrictions", + "Configuration status": "Configuration status", + "SSO setup is incomplete": "SSO setup is incomplete", + "Complete the required Microsoft configuration before enforcing Microsoft-only login.": "Complete the required Microsoft configuration before enforcing Microsoft-only login.", + "Login policy": "Login policy", + "Allow Microsoft-only login (disable local password login)": "Allow Microsoft-only login (disable local password login)", + "Remember login after Microsoft sign-in": "Remember login after Microsoft sign-in", + "Access and profile sync": "Access and profile sync", + "Profile sync": "Profile sync", + "App credentials (advanced)": "App credentials (advanced)", "Remember token lifetime is invalid": "Remember token lifetime is invalid" } diff --git a/lib/Http/AccessControl.php b/lib/Http/AccessControl.php index c0349bf..9254e66 100644 --- a/lib/Http/AccessControl.php +++ b/lib/Http/AccessControl.php @@ -43,14 +43,6 @@ class AccessControl return true; } - // Check page/ prefix with slug lookup - if (str_starts_with($normalizedPath, 'page/')) { - $pageSlug = substr($normalizedPath, strlen('page/')); - if (in_array($pageSlug, $this->configuredPublicPaths, true)) { - return true; - } - } - // Check always-public prefixes foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) { if (str_starts_with($normalizedPath, $prefix)) { diff --git a/lib/Support/helpers/ui.php b/lib/Support/helpers/ui.php index e678e86..3e5505d 100644 --- a/lib/Support/helpers/ui.php +++ b/lib/Support/helpers/ui.php @@ -430,7 +430,7 @@ function navActivePublicPages(array $extraSlugs = []): array } // "page/" routes and known standalone slugs should highlight the same nav entry. - $publicSlugs = array_merge(['imprint', 'privacy'], $extraSlugs); + $publicSlugs = array_merge(['imprint', 'privacy', 'terms'], $extraSlugs); $isActive = strpos($current, 'page/') === 0 || in_array($current, $publicSlugs, true); return [ diff --git a/pages/admin/tenants/_form.phtml b/pages/admin/tenants/_form.phtml index d283351..70241cd 100644 --- a/pages/admin/tenants/_form.phtml +++ b/pages/admin/tenants/_form.phtml @@ -104,6 +104,43 @@ $ssoStatusTiles = [ 'tone' => $ssoCredentialSource === 'shared' ? 'info' : 'neutral', ], ]; +$allowedDomainsList = preg_split('/[\s,;]+/', strtolower($allowedDomains), -1, PREG_SPLIT_NO_EMPTY); +$allowedDomainsList = is_array($allowedDomainsList) ? array_values(array_unique($allowedDomainsList)) : []; +$allowedDomainsCount = count($allowedDomainsList); +$syncFieldCount = count($syncProfileFields); + +$ssoSetupBadgeVariant = $microsoftEnabled ? 'success' : 'neutral'; +$ssoSetupBadgeLabel = sprintf( + '%s: %s', + t('Microsoft login'), + $microsoftEnabled ? t('Active') : t('Inactive') +); +$ssoShowConfigBadge = $microsoftEnabled; +$ssoConfigBadgeVariant = $microsoftEnabled ? ($ssoConfigComplete ? 'success' : 'warning') : 'neutral'; +$ssoConfigBadgeLabel = sprintf( + '%s: %s', + t('Configuration status'), + $ssoConfigComplete ? t('Complete') : t('Incomplete') +); + +$ssoPolicyBadgeVariant = match ($ssoPasswordMode) { + 'microsoft_only' => 'warning', + 'local_and_microsoft' => 'info', + default => 'neutral', +}; +$ssoSyncBadgeVariant = $syncProfileOnLogin ? 'info' : 'neutral'; +$ssoSyncBadgeLabel = $syncProfileOnLogin ? t('Active') : t('Inactive'); +$ssoDomainsBadgeVariant = $allowedDomainsCount > 0 ? 'info' : 'neutral'; +$ssoDomainsBadgeLabel = $allowedDomainsCount > 0 + ? sprintf('%d %s', $allowedDomainsCount, t('Allowed email domains')) + : t('No domain restrictions'); +$ssoCredentialsBadgeVariant = $ssoCredentialSource === 'shared' ? 'info' : 'warning'; +$ssoCredentialsBadgeLabel = $ssoCredentialSourceLabel; + +$openSetupCard = $detailsOpenAll || !$microsoftEnabled || ($microsoftEnabled && !$ssoConfigComplete); +$openPolicyCard = $detailsOpenAll || ($microsoftEnabled && $microsoftEnforce); +$openAccessCard = $detailsOpenAll || ($microsoftEnabled && ($allowedDomainsCount > 0 || $syncProfileOnLogin)); +$openOverrideCard = $detailsOpenAll; ?>
@@ -446,137 +483,188 @@ $ssoStatusTiles = [ ?> - - + +
+
+ + + + + +
-
- - - -
- - - -
- -
- - -
- -
- - -
- -
- - - -
-
- -
- $syncFieldLabel): ?> - - -
- +
> + + + + + + + + + +
+ + +

- - + +
-
+ -
- -
- - -
-
@@ -698,4 +786,4 @@ $ssoStatusTiles = [ - \ No newline at end of file + diff --git a/pages/page/copy-language().php b/pages/page/copy-language().php index e99f282..a36e8b3 100644 --- a/pages/page/copy-language().php +++ b/pages/page/copy-language().php @@ -4,6 +4,8 @@ use MintyPHP\Http\Request; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\I18n; use MintyPHP\Router; +use MintyPHP\Service\Access\SettingsAuthorizationPolicy; +use MintyPHP\Service\Access\UiAccessService; use MintyPHP\Service\Content\PageService; use MintyPHP\Session; use MintyPHP\Support\Flash; @@ -18,8 +20,18 @@ if ($currentUserId <= 0) { Router::redirect('login'); } +$return = Request::safeReturnTarget((string) (requestInput()->bodyAll()['return'] ?? '')); +$canEdit = app(UiAccessService::class)->allow( + $currentUserId, + SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE +); +if (!$canEdit) { + http_response_code(403); + Flash::error(t('You are not allowed to edit this page'), $return, 'page_forbidden'); + Router::redirect($return !== '' ? $return : ''); +} + if (!Session::checkCsrfToken()) { - $return = Request::safeReturnTarget((string) (requestInput()->bodyAll()['return'] ?? '')); Flash::error(t('Form expired, please try again'), $return, 'page_csrf'); Router::redirect($return !== '' ? $return : ''); } @@ -28,7 +40,6 @@ $slug = trim((string) (requestInput()->bodyAll()['slug'] ?? '')); $fromLocale = trim((string) (requestInput()->bodyAll()['from_locale'] ?? (I18n::$locale ?? ''))); $toLocale = trim((string) (requestInput()->bodyAll()['to_locale'] ?? '')); -$return = Request::safeReturnTarget((string) (requestInput()->bodyAll()['return'] ?? '')); if ($return === '' && $slug !== '') { $return = 'page/' . $slug; } diff --git a/pages/page/index($slug).php b/pages/page/index($slug).php index 2869c06..1089e5c 100644 --- a/pages/page/index($slug).php +++ b/pages/page/index($slug).php @@ -5,6 +5,8 @@ use MintyPHP\Http\Request; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\I18n; use MintyPHP\Router; +use MintyPHP\Service\Access\SettingsAuthorizationPolicy; +use MintyPHP\Service\Access\UiAccessService; use MintyPHP\Service\Content\PageService; use MintyPHP\Session; use MintyPHP\Support\Flash; @@ -28,7 +30,10 @@ if ($notFound) { $user = $session['user'] ?? []; $currentUserId = (int) ($user['id'] ?? 0); -$canEdit = $currentUserId > 0; +$canEdit = app(UiAccessService::class)->allow( + $currentUserId, + SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE +); $returnPath = Request::path(); if ($returnPath === '') { $returnPath = 'page/' . $slug; diff --git a/pages/page/index(default).phtml b/pages/page/index(default).phtml new file mode 100644 index 0000000..0ed1f0d --- /dev/null +++ b/pages/page/index(default).phtml @@ -0,0 +1,113 @@ + +
+
+ +

+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + +
diff --git a/pages/public-page/index($slug).php b/pages/public-page/index($slug).php new file mode 100644 index 0000000..47f3eb5 --- /dev/null +++ b/pages/public-page/index($slug).php @@ -0,0 +1,47 @@ +all(); + +$slug = trim((string) ($slug ?? '')); +if ($slug === '') { + Router::redirect(''); +} + +$currentUserId = (int) (($session['user']['id'] ?? 0)); +if ($currentUserId > 0) { + Router::redirect(Request::withLocale('page/' . $slug, I18n::$locale ?? I18n::$defaultLocale)); +} + +$pageBundle = PageService::findBySlugWithLocale($slug, I18n::$locale ?? null); +$page = $pageBundle['page'] ?? null; +$pageContent = $pageBundle['content'] ?? null; +$notFound = $page === null; +if ($notFound) { + http_response_code(404); + Buffer::set('title', t('Page not found')); +} + +$returnPath = Request::path(); +if ($returnPath === '') { + $returnPath = $slug; +} +$formAction = '/' . ltrim($returnPath, '/'); + +$contentJson = ''; +if (is_array($pageContent) && isset($pageContent['content'])) { + $contentJson = (string) $pageContent['content']; +} +if ($contentJson === '') { + $contentJson = json_encode(['blocks' => []], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} + +$canEdit = false; +$editMode = false; diff --git a/pages/page/index(page).phtml b/pages/public-page/index(page).phtml similarity index 100% rename from pages/page/index(page).phtml rename to pages/public-page/index(page).phtml diff --git a/templates/page.phtml b/templates/page.phtml index 9d1120d..bac7517 100644 --- a/templates/page.phtml +++ b/templates/page.phtml @@ -41,6 +41,7 @@ if ($bufferTitle !== '') {
  • +
diff --git a/templates/partials/app-footer.phtml b/templates/partials/app-footer.phtml index ca3bf53..02296a7 100644 --- a/templates/partials/app-footer.phtml +++ b/templates/partials/app-footer.phtml @@ -1,3 +1,7 @@ +