From c14d42f198659e18bc3b942c4eae730601e37f85 Mon Sep 17 00:00:00 2001 From: fs Date: Sat, 25 Apr 2026 08:50:05 +0200 Subject: [PATCH] refactor(settings): split security into 4 focused tiles Extracts user-lifecycle, audit and telemetry from the security subpage into their own tiles, and renames the slimmed-down security subpage to account-access for a clearer scope. Each subpage now has at most three detail cards instead of the eight previously crowded into security. Hub gains four tiles, sub-action redirects (expire-remember-tokens, run-user-lifecycle) move to their new sections, architecture tests track the new section list and i18n adds the new labels in de + en. Co-Authored-By: Claude Opus 4.7 (1M context) --- i18n/default_de.json | 14 + i18n/default_en.json | 14 + .../{security().php => account-access().php} | 19 +- .../settings/account-access(default).phtml | 181 +++++++++ pages/admin/settings/audit().php | 84 +++++ pages/admin/settings/audit(default).phtml | 87 +++++ .../settings/expire-remember-tokens().php | 8 +- pages/admin/settings/index(default).phtml | 30 +- pages/admin/settings/run-user-lifecycle().php | 12 +- pages/admin/settings/security(default).phtml | 344 ------------------ pages/admin/settings/telemetry().php | 85 +++++ pages/admin/settings/telemetry(default).phtml | 136 +++++++ pages/admin/settings/user-lifecycle().php | 84 +++++ .../settings/user-lifecycle(default).phtml | 107 ++++++ .../AuthzAdminSettingsContractTest.php | 2 +- .../AuthzUiTemplateContractTest.php | 2 +- .../DetailActionPolicyContractFiles.php | 8 +- .../Architecture/DetailPageContractFiles.php | 5 +- 18 files changed, 846 insertions(+), 376 deletions(-) rename pages/admin/settings/{security().php => account-access().php} (82%) create mode 100644 pages/admin/settings/account-access(default).phtml create mode 100644 pages/admin/settings/audit().php create mode 100644 pages/admin/settings/audit(default).phtml delete mode 100644 pages/admin/settings/security(default).phtml create mode 100644 pages/admin/settings/telemetry().php create mode 100644 pages/admin/settings/telemetry(default).phtml create mode 100644 pages/admin/settings/user-lifecycle().php create mode 100644 pages/admin/settings/user-lifecycle(default).phtml diff --git a/i18n/default_de.json b/i18n/default_de.json index 6415623..245062e 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -33,6 +33,8 @@ "Access emails sent to %d users, %f failed": "Zugangsdaten an %d Benutzer gesendet, %f fehlgeschlagen", "Access PDF": "Zugangs-PDF", "Account": "Konto", + "Account access": "Kontozugang", + "Account access settings": "Kontozugang-Einstellungen", "Account is inactive": "Konto ist deaktiviert", "Action": "Aktion", "Actions": "Aktionen", @@ -133,6 +135,8 @@ "Attempts": "Versuche", "Attribute mapping and sync": "Attributzuordnung und Synchronisierung", "Audit": "Audit", + "Audit log": "Audit-Log", + "Audit log settings": "Audit-Log-Einstellungen", "Authority endpoint": "Authority-Endpunkt", "Authority URL": "Authority-URL", "Auto-delete is ignored while auto-deactivate is disabled": "Auto-Löschung wird ignoriert, solange Auto-Deaktivierung deaktiviert ist", @@ -450,6 +454,7 @@ "Fri": "Fr", "Frontend": "Frontend", "Frontend telemetry": "Frontend-Telemetrie", + "Frontend telemetry collection and event allowlist": "Frontend-Telemetrie-Erfassung und Event-Allowlist", "General": "Allgemein", "General settings": "Allgemeine Einstellungen", "Generate access PDFs for selected users?": "Zugangs-PDFs für ausgewählte Benutzer erstellen?", @@ -502,6 +507,7 @@ "Inactive tenants": "Inaktive Mandanten", "Inactive tenants are excluded from user access and tenant-scoped data.": "Inaktive Mandanten werden vom Benutzerzugriff und mandantenspezifischen Daten ausgeschlossen.", "Inactive users": "Inaktive Benutzer", + "Inactivity deactivation and deletion policy": "Richtlinie für Inaktivitäts-Deaktivierung und Löschung", "Incomplete": "Unvollständig", "Inherit global default": "Globalen Standard verwenden", "Internal Server Error": "Interner Serverfehler", @@ -865,6 +871,7 @@ "Registration controls whether new users can create an account.": "Hier wird gesteuert, ob neue Benutzer ein Konto erstellen dürfen.", "Registration is currently disabled": "Registrierung ist derzeit deaktiviert.", "Registration successful! Please check your email for the verification code.": "Registrierung erfolgreich! Bitte prüfe deine E-Mails für den Bestätigungscode.", + "Registration, sessions, login persistence": "Registrierung, Sitzungen, Login-Persistenz", "Remember login after Microsoft sign-in": "Angemeldet bleiben nach Microsoft-Login", "Remember me": "Angemeldet bleiben", "Remember token lifetime (days)": "Token-Lebensdauer Angemeldet-bleiben (Tage)", @@ -934,6 +941,7 @@ "Rows total": "Zeilen gesamt", "Rules": "Regeln", "Run history": "Laufhistorie", + "Run lifecycle now": "Lebenszyklus jetzt ausführen", "Run now": "Jetzt ausführen", "Run preview": "Vorschau ausführen", "Run user lifecycle now": "Benutzer-Lifecycle jetzt ausführen", @@ -1138,6 +1146,7 @@ "System audit controls whether security events are logged and how long they are kept.": "Hier wird gesteuert, ob Sicherheitsereignisse protokolliert werden und wie lange sie aufbewahrt werden.", "System audit entry not found": "System-Protokoll-Eintrag nicht gefunden", "System audit events (24h)": "System-Protokoll-Ereignisse (24h)", + "System audit log enable and retention": "System-Audit-Log aktivieren und Aufbewahrung", "System audit logs": "System-Protokolle", "System audit risks (24h)": "System-Protokoll-Risiken (24h)", "System Info": "Systeminfo", @@ -1153,7 +1162,9 @@ "Target type": "Zieltyp", "Target UUID": "Ziel-UUID", "Tax number": "Steuernummer", + "Telemetry": "Telemetrie", "Telemetry helps detect recurring UI issues and failed requests in production.": "Telemetry hilft, wiederkehrende UI-Probleme und fehlgeschlagene Requests in Produktion zu erkennen.", + "Telemetry settings": "Telemetrie-Einstellungen", "Template": "Vorlage", "Tenant": "Mandant", "Tenant #%d": "Mandant #%d", @@ -1196,6 +1207,7 @@ "These limits define default and maximum lifetimes for newly issued API tokens.": "Diese Grenzwerte definieren Standard- und Maximallaufzeit für neu ausgestellte API-Tokens.", "This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.", "This action revokes all active API tokens immediately across all users.": "Diese Aktion widerruft sofort alle aktiven API-Tokens über alle Benutzer hinweg.", + "This runs the lifecycle policy immediately and may deactivate or delete users.": "Dies führt die Lebenszyklus-Richtlinie sofort aus und kann Benutzer deaktivieren oder löschen.", "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.": "Diese geplante Aufgabe ist nicht mehr registriert und kann nicht bearbeitet werden. Sie wurde durch eine neuere Version ersetzt.", "This setting controls the application name shown across the UI.": "Diese Einstellung steuert den in der gesamten UI angezeigten Anwendungsnamen.", "This setting controls the default language for users without a personal preference.": "Diese Einstellung steuert die Standardsprache für Benutzer ohne persönliche Präferenz.", @@ -1277,12 +1289,14 @@ "User ID": "Benutzer-ID", "User inactivity deactivation period is invalid": "Zeitraum für automatische Deaktivierung ist ungültig", "User inactivity deletion period is invalid": "Zeitraum für automatische Löschung ist ungültig", + "User lifecycle": "Benutzer-Lebenszyklus", "User lifecycle already running": "Benutzer-Lifecycle läuft bereits", "User lifecycle completed: %d deactivated, %d deleted": "Benutzer-Lifecycle abgeschlossen: %d deaktiviert, %d gelöscht", "User lifecycle defines when inactive users are deactivated or deleted.": "Hier wird festgelegt, wann inaktive Benutzer deaktiviert oder gelöscht werden.", "User lifecycle failed": "Benutzer-Lifecycle fehlgeschlagen", "User lifecycle logs": "Benutzer-Lifecycle-Protokolle", "User lifecycle policy": "Benutzer-Lifecycle-Regel", + "User lifecycle settings": "Benutzer-Lebenszyklus-Einstellungen", "User not found": "Benutzer nicht gefunden", "User override disabled": "Benutzer-Override deaktiviert", "User override enabled": "Benutzer-Override aktiviert", diff --git a/i18n/default_en.json b/i18n/default_en.json index 733ae73..2d487f7 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -33,6 +33,8 @@ "Access emails sent to %d users, %f failed": "Access emails sent to %d users, %f failed", "Access PDF": "Access PDF", "Account": "Account", + "Account access": "Account access", + "Account access settings": "Account access settings", "Account is inactive": "Account is inactive", "Action": "Action", "Actions": "Actions", @@ -133,6 +135,8 @@ "Attempts": "Attempts", "Attribute mapping and sync": "Attribute mapping and sync", "Audit": "Audit", + "Audit log": "Audit log", + "Audit log settings": "Audit log settings", "Authority endpoint": "Authority endpoint", "Authority URL": "Authority URL", "Auto-delete is ignored while auto-deactivate is disabled": "Auto-delete is ignored while auto-deactivate is disabled", @@ -450,6 +454,7 @@ "Fri": "Fri", "Frontend": "Frontend", "Frontend telemetry": "Frontend telemetry", + "Frontend telemetry collection and event allowlist": "Frontend telemetry collection and event allowlist", "General": "General", "General settings": "General settings", "Generate access PDFs for selected users?": "Generate access PDFs for selected users?", @@ -502,6 +507,7 @@ "Inactive tenants": "Inactive tenants", "Inactive tenants are excluded from user access and tenant-scoped data.": "Inactive tenants are excluded from user access and tenant-scoped data.", "Inactive users": "Inactive users", + "Inactivity deactivation and deletion policy": "Inactivity deactivation and deletion policy", "Incomplete": "Incomplete", "Inherit global default": "Inherit global default", "Internal Server Error": "Internal Server Error", @@ -865,6 +871,7 @@ "Registration controls whether new users can create an account.": "Registration controls whether new users can create an account.", "Registration is currently disabled": "Registration is currently disabled.", "Registration successful! Please check your email for the verification code.": "Registration successful! Please check your email for the verification code.", + "Registration, sessions, login persistence": "Registration, sessions, login persistence", "Remember login after Microsoft sign-in": "Remember login after Microsoft sign-in", "Remember me": "Remember me", "Remember token lifetime (days)": "Remember token lifetime (days)", @@ -934,6 +941,7 @@ "Rows total": "Rows total", "Rules": "Rules", "Run history": "Run history", + "Run lifecycle now": "Run lifecycle now", "Run now": "Run now", "Run preview": "Run preview", "Run user lifecycle now": "Run user lifecycle now", @@ -1138,6 +1146,7 @@ "System audit controls whether security events are logged and how long they are kept.": "System audit controls whether security events are logged and how long they are kept.", "System audit entry not found": "System audit entry not found", "System audit events (24h)": "System audit events (24h)", + "System audit log enable and retention": "System audit log enable and retention", "System audit logs": "System audit logs", "System audit risks (24h)": "System audit risks (24h)", "System Info": "System Info", @@ -1153,7 +1162,9 @@ "Target type": "Target type", "Target UUID": "Target UUID", "Tax number": "Tax number", + "Telemetry": "Telemetry", "Telemetry helps detect recurring UI issues and failed requests in production.": "Telemetry helps detect recurring UI issues and failed requests in production.", + "Telemetry settings": "Telemetry settings", "Template": "Template", "Tenant": "Tenant", "Tenant #%d": "Tenant #%d", @@ -1196,6 +1207,7 @@ "These limits define default and maximum lifetimes for newly issued API tokens.": "These limits define default and maximum lifetimes for newly issued API tokens.", "This action cannot be undone.": "This action cannot be undone.", "This action revokes all active API tokens immediately across all users.": "This action revokes all active API tokens immediately across all users.", + "This runs the lifecycle policy immediately and may deactivate or delete users.": "This runs the lifecycle policy immediately and may deactivate or delete users.", "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.": "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.", "This setting controls the application name shown across the UI.": "This setting controls the application name shown across the UI.", "This setting controls the default language for users without a personal preference.": "This setting controls the default language for users without a personal preference.", @@ -1277,12 +1289,14 @@ "User ID": "User ID", "User inactivity deactivation period is invalid": "User inactivity deactivation period is invalid", "User inactivity deletion period is invalid": "User inactivity deletion period is invalid", + "User lifecycle": "User lifecycle", "User lifecycle already running": "User lifecycle already running", "User lifecycle completed: %d deactivated, %d deleted": "User lifecycle completed: %d deactivated, %d deleted", "User lifecycle defines when inactive users are deactivated or deleted.": "User lifecycle defines when inactive users are deactivated or deleted.", "User lifecycle failed": "User lifecycle failed", "User lifecycle logs": "User lifecycle logs", "User lifecycle policy": "User lifecycle policy", + "User lifecycle settings": "User lifecycle settings", "User not found": "User not found", "User override disabled": "User override disabled", "User override enabled": "User override enabled", diff --git a/pages/admin/settings/security().php b/pages/admin/settings/account-access().php similarity index 82% rename from pages/admin/settings/security().php rename to pages/admin/settings/account-access().php index c6f137a..ce1829b 100644 --- a/pages/admin/settings/security().php +++ b/pages/admin/settings/account-access().php @@ -29,7 +29,7 @@ $values = is_array($pageData['values'] ?? null) ? $pageData['values'] : []; $settings = is_array($pageData['settings'] ?? null) ? $pageData['settings'] : []; $activeLoginTokens = (int) ($pageData['active_login_tokens'] ?? 0); -if ($request->isMethod('POST') && !actionRequireCsrf('admin/settings/security', 'admin/settings/security', 'csrf_expired')) { +if ($request->isMethod('POST') && !actionRequireCsrf('admin/settings/account-access', 'admin/settings/account-access', 'csrf_expired')) { return; } @@ -47,13 +47,6 @@ if ($request->isMethod('POST')) { 'session_absolute_timeout_hours', 'remember_token_lifetime_days', 'microsoft_auto_remember_default', - 'user_inactivity_deactivate_days', - 'user_inactivity_delete_days', - 'system_audit_enabled', - 'system_audit_retention_days', - 'frontend_telemetry_enabled', - 'frontend_telemetry_sample_rate', - 'frontend_telemetry_allowed_events', ]; $mergedPost = settingsSectionMergePost($values, $request->bodyAll(), $sectionKeys); @@ -75,21 +68,21 @@ if ($request->isMethod('POST')) { } if ($errorBag->hasAny()) { - flashFormErrors($errorBag, 'admin/settings/security', 'settings_update_error'); - Router::redirect('admin/settings/security'); + flashFormErrors($errorBag, 'admin/settings/account-access', 'settings_update_error'); + Router::redirect('admin/settings/account-access'); } $redirectTarget = ((string) $request->body('action', 'save') === 'save_close') ? 'admin/settings' - : 'admin/settings/security'; + : 'admin/settings/account-access'; Flash::success('Settings updated', $redirectTarget, 'settings_updated'); Router::redirect($redirectTarget); } -Buffer::set('title', t('Security settings')); +Buffer::set('title', t('Account access settings')); $breadcrumbs = [ ['label' => t('Home'), 'path' => 'admin'], ['label' => t('Settings'), 'path' => 'admin/settings'], - ['label' => t('Security')], + ['label' => t('Account access')], ]; diff --git a/pages/admin/settings/account-access(default).phtml b/pages/admin/settings/account-access(default).phtml new file mode 100644 index 0000000..45c1133 --- /dev/null +++ b/pages/admin/settings/account-access(default).phtml @@ -0,0 +1,181 @@ + + +
+
+ t('Account access settings'), + 'backHref' => 'admin/settings', + 'backTitle' => t('Cancel'), + 'actions' => $canUpdateSettings ? [ + [ + 'form' => 'settings-account-access-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => 'settings-account-access-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ] : [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
+
+ + + + + + +
+
+ +
+
+ + + + +
+
+
+ +
+ + + + + + + +
+
+ +
+
+ + +
+
+
+ +
+ + + + + + +
+
+ +
+
+ +
+ + + + + +
+
+
+
+ + +
+ + + + + + +
+
+ +
+ +
+
+ + + +
+
+ +
diff --git a/pages/admin/settings/audit().php b/pages/admin/settings/audit().php new file mode 100644 index 0000000..168f39e --- /dev/null +++ b/pages/admin/settings/audit().php @@ -0,0 +1,84 @@ +all(); +Guard::requireLogin(); +$request = requestInput(); + +$currentUserId = (int) ($session['user']['id'] ?? 0); +$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class); +$viewDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$viewDecision->isAllowed()) { + Guard::deny(); +} +$viewCapabilities = $viewDecision->attribute('capabilities', []); +$canUpdateSettings = is_array($viewCapabilities) ? (bool) ($viewCapabilities['can_update_settings'] ?? false) : false; + +$adminSettingsService = app(AdminSettingsService::class); +$pageData = $adminSettingsService->buildPageData(); +$values = is_array($pageData['values'] ?? null) ? $pageData['values'] : []; +$settings = is_array($pageData['settings'] ?? null) ? $pageData['settings'] : []; + +if ($request->isMethod('POST') && !actionRequireCsrf('admin/settings/audit', 'admin/settings/audit', 'csrf_expired')) { + return; +} + +if ($request->isMethod('POST')) { + $updateDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE, [ + 'actor_user_id' => $currentUserId, + ]); + if (!$updateDecision->isAllowed()) { + Guard::deny(); + } + + $sectionKeys = [ + 'system_audit_enabled', + 'system_audit_retention_days', + ]; + $mergedPost = settingsSectionMergePost($values, $request->bodyAll(), $sectionKeys); + + $updateResult = $adminSettingsService->updateFromAdmin($mergedPost); + $errorBag = formErrors(); + foreach ((array) ($updateResult['errors'] ?? []) as $error) { + if (is_array($error)) { + $message = trim((string) ($error['message'] ?? '')); + $field = trim((string) ($error['field'] ?? 'input')); + if ($message !== '') { + $errorBag->add($field !== '' ? $field : 'input', $message); + } + continue; + } + $message = trim((string) $error); + if ($message !== '') { + $errorBag->addGlobal($message); + } + } + + if ($errorBag->hasAny()) { + flashFormErrors($errorBag, 'admin/settings/audit', 'settings_update_error'); + Router::redirect('admin/settings/audit'); + } + + $redirectTarget = ((string) $request->body('action', 'save') === 'save_close') + ? 'admin/settings' + : 'admin/settings/audit'; + Flash::success('Settings updated', $redirectTarget, 'settings_updated'); + Router::redirect($redirectTarget); +} + +Buffer::set('title', t('Audit log settings')); + +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Settings'), 'path' => 'admin/settings'], + ['label' => t('Audit log')], +]; diff --git a/pages/admin/settings/audit(default).phtml b/pages/admin/settings/audit(default).phtml new file mode 100644 index 0000000..5e76b47 --- /dev/null +++ b/pages/admin/settings/audit(default).phtml @@ -0,0 +1,87 @@ + + +
+
+ t('Audit log settings'), + 'backHref' => 'admin/settings', + 'backTitle' => t('Cancel'), + 'actions' => $canUpdateSettings ? [ + [ + 'form' => 'settings-audit-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => 'settings-audit-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ] : [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
+
+ + + + + + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
diff --git a/pages/admin/settings/expire-remember-tokens().php b/pages/admin/settings/expire-remember-tokens().php index 18c29d3..1439807 100644 --- a/pages/admin/settings/expire-remember-tokens().php +++ b/pages/admin/settings/expire-remember-tokens().php @@ -18,15 +18,15 @@ if (!$decision->isAllowed()) { Guard::deny(); } -if (!actionRequirePost('admin/settings/security')) { +if (!actionRequirePost('admin/settings/account-access')) { return; } -if (!actionRequireCsrf('admin/settings/security', 'admin/settings/security', 'settings_tokens_expire')) { +if (!actionRequireCsrf('admin/settings/account-access', 'admin/settings/account-access', 'settings_tokens_expire')) { return; } $rememberMeService = app(\MintyPHP\Service\Auth\RememberMeService::class); $count = $rememberMeService->expireAllTokensByAdmin(); -Flash::success(sprintf(t('%d login tokens expired'), $count), 'admin/settings/security', 'login_tokens_expired'); -Router::redirect('admin/settings/security'); +Flash::success(sprintf(t('%d login tokens expired'), $count), 'admin/settings/account-access', 'login_tokens_expired'); +Router::redirect('admin/settings/account-access'); diff --git a/pages/admin/settings/index(default).phtml b/pages/admin/settings/index(default).phtml index 633e8b5..7ba8139 100644 --- a/pages/admin/settings/index(default).phtml +++ b/pages/admin/settings/index(default).phtml @@ -24,12 +24,36 @@ 'tooltip' => t('App title, language, user creation defaults'), ]); appTile([ - 'href' => 'admin/settings/security', - 'label' => t('Security'), + 'href' => 'admin/settings/account-access', + 'label' => t('Account access'), 'icon' => 'bi bi-shield-lock', 'iconBg' => '#ede9fe', 'iconColor' => '#7c3aed', - 'tooltip' => t('Sessions, registration, lifecycle, audit, telemetry'), + 'tooltip' => t('Registration, sessions, login persistence'), + ]); + appTile([ + 'href' => 'admin/settings/user-lifecycle', + 'label' => t('User lifecycle'), + 'icon' => 'bi bi-arrow-repeat', + 'iconBg' => '#fee2e2', + 'iconColor' => '#b91c1c', + 'tooltip' => t('Inactivity deactivation and deletion policy'), + ]); + appTile([ + 'href' => 'admin/settings/audit', + 'label' => t('Audit log'), + 'icon' => 'bi bi-journal-text', + 'iconBg' => '#fff7ed', + 'iconColor' => '#c2410c', + 'tooltip' => t('System audit log enable and retention'), + ]); + appTile([ + 'href' => 'admin/settings/telemetry', + 'label' => t('Telemetry'), + 'icon' => 'bi bi-graph-up', + 'iconBg' => '#ecfdf5', + 'iconColor' => '#047857', + 'tooltip' => t('Frontend telemetry collection and event allowlist'), ]); appTile([ 'href' => 'admin/settings/email', diff --git a/pages/admin/settings/run-user-lifecycle().php b/pages/admin/settings/run-user-lifecycle().php index f12acb9..3f11a65 100644 --- a/pages/admin/settings/run-user-lifecycle().php +++ b/pages/admin/settings/run-user-lifecycle().php @@ -18,11 +18,11 @@ if (!$decision->isAllowed()) { Guard::deny(); } -if (!actionRequirePost('admin/settings/security')) { +if (!actionRequirePost('admin/settings/user-lifecycle')) { return; } -if (!actionRequireCsrf('admin/settings/security', 'admin/settings/security', 'user_lifecycle_run')) { +if (!actionRequireCsrf('admin/settings/user-lifecycle', 'admin/settings/user-lifecycle', 'user_lifecycle_run')) { return; } @@ -35,8 +35,8 @@ if (!($result['ok'] ?? false)) { } else { $errorBag->addGlobal('User lifecycle failed'); } - flashFormErrors($errorBag, 'admin/settings/security', 'user_lifecycle_run'); - Router::redirect('admin/settings/security'); + flashFormErrors($errorBag, 'admin/settings/user-lifecycle', 'user_lifecycle_run'); + Router::redirect('admin/settings/user-lifecycle'); } Flash::success( @@ -45,7 +45,7 @@ Flash::success( (int) ($result['deactivated_count'] ?? 0), (int) ($result['deleted_count'] ?? 0) ), - 'admin/settings/security', + 'admin/settings/user-lifecycle', 'user_lifecycle_completed' ); -Router::redirect('admin/settings/security'); +Router::redirect('admin/settings/user-lifecycle'); diff --git a/pages/admin/settings/security(default).phtml b/pages/admin/settings/security(default).phtml deleted file mode 100644 index fa196d2..0000000 --- a/pages/admin/settings/security(default).phtml +++ /dev/null @@ -1,344 +0,0 @@ - strtolower(trim((string) $entry)), - $frontendTelemetryAllowedEvents -)))); -if ($frontendTelemetryAllowedEvents === []) { - $frontendTelemetryAllowedEvents = ['warn_once', 'ajax_error']; -} -$appRegistrationDesc = $settings['app_registration']['description'] ?? 'setting.app_registration'; -$sessionIdleTimeoutMinutesDesc = $settings['session_idle_timeout_minutes']['description'] ?? 'setting.session_idle_timeout_minutes'; -$sessionAbsoluteTimeoutHoursDesc = $settings['session_absolute_timeout_hours']['description'] ?? 'setting.session_absolute_timeout_hours'; -$rememberTokenLifetimeDaysDesc = $settings['remember_token_lifetime_days']['description'] ?? 'setting.remember_token_lifetime_days'; -$microsoftAutoRememberDefaultDesc = $settings['microsoft_auto_remember_default']['description'] ?? 'setting.microsoft_auto_remember_default'; -$userInactivityDeactivateDaysDesc = $settings['user_inactivity_deactivate_days']['description'] ?? 'setting.user_inactivity_deactivate_days'; -$userInactivityDeleteDaysDesc = $settings['user_inactivity_delete_days']['description'] ?? 'setting.user_inactivity_delete_days'; -$systemAuditRetentionDaysDesc = $settings['system_audit_retention_days']['description'] ?? 'setting.system_audit_retention_days'; -$activeLoginTokens = (int) ($activeLoginTokens ?? 0); -$canUpdateSettings = (bool) ($canUpdateSettings ?? false); -$isReadOnly = !$canUpdateSettings; -$readonlyAttr = $isReadOnly ? 'readonly' : ''; -$disabledAttr = $isReadOnly ? 'disabled' : ''; -?> - -
-
- t('Security settings'), - 'backHref' => 'admin/settings', - 'backTitle' => t('Cancel'), - 'actions' => $canUpdateSettings ? [ - [ - 'form' => 'settings-security-form', - 'name' => 'action', - 'value' => 'save', - 'class' => 'secondary outline', - 'label' => t('Save'), - ], - [ - 'form' => 'settings-security-form', - 'name' => 'action', - 'value' => 'save_close', - 'class' => 'primary', - 'label' => t('Save & close'), - ], - ] : [], - ]; - require templatePath('partials/app-details-titlebar.phtml'); - ?> - -
-
- - - - - - -
-
- -
-
- - - - -
-
-
- -
- - - - - - - -
-
- -
-
- - -
-
-
- -
- - - - - - -
-
- -
-
- -
- - - - - -
-
-
-
- -
- - - - - - - -
-
- -
-
- - -
- - - - -
-
- -
- - - - - - -
-
- -
- -
- -
-
- -
- - - - - - - -
-
- -
-
- - -
-
> - - -
-
-
- -
- - - - - - -
-
- -
-
- - - -
-
-
- - -
- - - - - - -
-
- -
- -
-
- - - -
-
- -
diff --git a/pages/admin/settings/telemetry().php b/pages/admin/settings/telemetry().php new file mode 100644 index 0000000..70ca049 --- /dev/null +++ b/pages/admin/settings/telemetry().php @@ -0,0 +1,85 @@ +all(); +Guard::requireLogin(); +$request = requestInput(); + +$currentUserId = (int) ($session['user']['id'] ?? 0); +$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class); +$viewDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$viewDecision->isAllowed()) { + Guard::deny(); +} +$viewCapabilities = $viewDecision->attribute('capabilities', []); +$canUpdateSettings = is_array($viewCapabilities) ? (bool) ($viewCapabilities['can_update_settings'] ?? false) : false; + +$adminSettingsService = app(AdminSettingsService::class); +$pageData = $adminSettingsService->buildPageData(); +$values = is_array($pageData['values'] ?? null) ? $pageData['values'] : []; +$settings = is_array($pageData['settings'] ?? null) ? $pageData['settings'] : []; + +if ($request->isMethod('POST') && !actionRequireCsrf('admin/settings/telemetry', 'admin/settings/telemetry', 'csrf_expired')) { + return; +} + +if ($request->isMethod('POST')) { + $updateDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE, [ + 'actor_user_id' => $currentUserId, + ]); + if (!$updateDecision->isAllowed()) { + Guard::deny(); + } + + $sectionKeys = [ + 'frontend_telemetry_enabled', + 'frontend_telemetry_sample_rate', + 'frontend_telemetry_allowed_events', + ]; + $mergedPost = settingsSectionMergePost($values, $request->bodyAll(), $sectionKeys); + + $updateResult = $adminSettingsService->updateFromAdmin($mergedPost); + $errorBag = formErrors(); + foreach ((array) ($updateResult['errors'] ?? []) as $error) { + if (is_array($error)) { + $message = trim((string) ($error['message'] ?? '')); + $field = trim((string) ($error['field'] ?? 'input')); + if ($message !== '') { + $errorBag->add($field !== '' ? $field : 'input', $message); + } + continue; + } + $message = trim((string) $error); + if ($message !== '') { + $errorBag->addGlobal($message); + } + } + + if ($errorBag->hasAny()) { + flashFormErrors($errorBag, 'admin/settings/telemetry', 'settings_update_error'); + Router::redirect('admin/settings/telemetry'); + } + + $redirectTarget = ((string) $request->body('action', 'save') === 'save_close') + ? 'admin/settings' + : 'admin/settings/telemetry'; + Flash::success('Settings updated', $redirectTarget, 'settings_updated'); + Router::redirect($redirectTarget); +} + +Buffer::set('title', t('Telemetry settings')); + +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Settings'), 'path' => 'admin/settings'], + ['label' => t('Telemetry')], +]; diff --git a/pages/admin/settings/telemetry(default).phtml b/pages/admin/settings/telemetry(default).phtml new file mode 100644 index 0000000..d2062fe --- /dev/null +++ b/pages/admin/settings/telemetry(default).phtml @@ -0,0 +1,136 @@ + strtolower(trim((string) $entry)), + $frontendTelemetryAllowedEvents +)))); +if ($frontendTelemetryAllowedEvents === []) { + $frontendTelemetryAllowedEvents = ['warn_once', 'ajax_error']; +} +$canUpdateSettings = (bool) ($canUpdateSettings ?? false); +$isReadOnly = !$canUpdateSettings; +$disabledAttr = $isReadOnly ? 'disabled' : ''; +?> + +
+
+ t('Telemetry settings'), + 'backHref' => 'admin/settings', + 'backTitle' => t('Cancel'), + 'actions' => $canUpdateSettings ? [ + [ + 'form' => 'settings-telemetry-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => 'settings-telemetry-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ] : [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
+
+ + + + + + + +
+
+ +
+
+ + +
+
> + + +
+
+
+ +
+ + + + + + +
+
+ +
+
+ + + +
+
+
+ + +
+
+ +
diff --git a/pages/admin/settings/user-lifecycle().php b/pages/admin/settings/user-lifecycle().php new file mode 100644 index 0000000..c3f98ec --- /dev/null +++ b/pages/admin/settings/user-lifecycle().php @@ -0,0 +1,84 @@ +all(); +Guard::requireLogin(); +$request = requestInput(); + +$currentUserId = (int) ($session['user']['id'] ?? 0); +$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class); +$viewDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$viewDecision->isAllowed()) { + Guard::deny(); +} +$viewCapabilities = $viewDecision->attribute('capabilities', []); +$canUpdateSettings = is_array($viewCapabilities) ? (bool) ($viewCapabilities['can_update_settings'] ?? false) : false; + +$adminSettingsService = app(AdminSettingsService::class); +$pageData = $adminSettingsService->buildPageData(); +$values = is_array($pageData['values'] ?? null) ? $pageData['values'] : []; +$settings = is_array($pageData['settings'] ?? null) ? $pageData['settings'] : []; + +if ($request->isMethod('POST') && !actionRequireCsrf('admin/settings/user-lifecycle', 'admin/settings/user-lifecycle', 'csrf_expired')) { + return; +} + +if ($request->isMethod('POST')) { + $updateDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE, [ + 'actor_user_id' => $currentUserId, + ]); + if (!$updateDecision->isAllowed()) { + Guard::deny(); + } + + $sectionKeys = [ + 'user_inactivity_deactivate_days', + 'user_inactivity_delete_days', + ]; + $mergedPost = settingsSectionMergePost($values, $request->bodyAll(), $sectionKeys); + + $updateResult = $adminSettingsService->updateFromAdmin($mergedPost); + $errorBag = formErrors(); + foreach ((array) ($updateResult['errors'] ?? []) as $error) { + if (is_array($error)) { + $message = trim((string) ($error['message'] ?? '')); + $field = trim((string) ($error['field'] ?? 'input')); + if ($message !== '') { + $errorBag->add($field !== '' ? $field : 'input', $message); + } + continue; + } + $message = trim((string) $error); + if ($message !== '') { + $errorBag->addGlobal($message); + } + } + + if ($errorBag->hasAny()) { + flashFormErrors($errorBag, 'admin/settings/user-lifecycle', 'settings_update_error'); + Router::redirect('admin/settings/user-lifecycle'); + } + + $redirectTarget = ((string) $request->body('action', 'save') === 'save_close') + ? 'admin/settings' + : 'admin/settings/user-lifecycle'; + Flash::success('Settings updated', $redirectTarget, 'settings_updated'); + Router::redirect($redirectTarget); +} + +Buffer::set('title', t('User lifecycle settings')); + +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Settings'), 'path' => 'admin/settings'], + ['label' => t('User lifecycle')], +]; diff --git a/pages/admin/settings/user-lifecycle(default).phtml b/pages/admin/settings/user-lifecycle(default).phtml new file mode 100644 index 0000000..79ae05f --- /dev/null +++ b/pages/admin/settings/user-lifecycle(default).phtml @@ -0,0 +1,107 @@ + + +
+
+ t('User lifecycle settings'), + 'backHref' => 'admin/settings', + 'backTitle' => t('Cancel'), + 'actions' => $canUpdateSettings ? [ + [ + 'form' => 'settings-user-lifecycle-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => 'settings-user-lifecycle-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ] : [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
+
+ + + + + + + +
+
+ +
+
+ + +
+
+
+ + +
+ + + +
+
+ +
+ +
+
+ + + +
+
+ +
diff --git a/tests/Architecture/AuthzAdminSettingsContractTest.php b/tests/Architecture/AuthzAdminSettingsContractTest.php index 4737345..ffaac51 100644 --- a/tests/Architecture/AuthzAdminSettingsContractTest.php +++ b/tests/Architecture/AuthzAdminSettingsContractTest.php @@ -16,7 +16,7 @@ class AuthzAdminSettingsContractTest extends TestCase $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW', $indexContent); // Subpages that expose a settings form: VIEW + UPDATE. - foreach (['general', 'security', 'email', 'api', 'sso'] as $section) { + foreach (['general', 'account-access', 'user-lifecycle', 'audit', 'telemetry', 'email', 'api', 'sso'] as $section) { $content = $this->readProjectFile("pages/admin/settings/{$section}().php"); $this->assertStringContainsString('AuthorizationService::class', $content, $section); $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW', $content, $section); diff --git a/tests/Architecture/AuthzUiTemplateContractTest.php b/tests/Architecture/AuthzUiTemplateContractTest.php index ce6efa1..a9c658e 100644 --- a/tests/Architecture/AuthzUiTemplateContractTest.php +++ b/tests/Architecture/AuthzUiTemplateContractTest.php @@ -59,7 +59,7 @@ class AuthzUiTemplateContractTest extends TestCase // Settings was split into a landing hub + per-section subpages. // The form-carrying subpages must use the server-side $canUpdateSettings // capability instead of the legacy can() helper. - foreach (['general', 'security', 'email', 'api', 'sso', 'branding'] as $section) { + foreach (['general', 'account-access', 'user-lifecycle', 'audit', 'telemetry', 'email', 'api', 'sso', 'branding'] as $section) { $templateContent = $this->readProjectFile("pages/admin/settings/{$section}(default).phtml"); $this->assertStringNotContainsString("can('settings.update')", $templateContent, $section); $this->assertStringContainsString('$canUpdateSettings', $templateContent, $section); diff --git a/tests/Architecture/DetailActionPolicyContractFiles.php b/tests/Architecture/DetailActionPolicyContractFiles.php index 3e935d0..a2bfbe4 100644 --- a/tests/Architecture/DetailActionPolicyContractFiles.php +++ b/tests/Architecture/DetailActionPolicyContractFiles.php @@ -18,9 +18,11 @@ final class DetailActionPolicyContractFiles 'pages/admin/roles/edit(default).phtml', 'pages/admin/permissions/edit(default).phtml', // Settings subpages that carry danger actions (and therefore need - // the data-detail-confirm-message contract) — general/email/sso/branding - // have no destructive buttons and are deliberately excluded. - 'pages/admin/settings/security(default).phtml', + // the data-detail-confirm-message contract). Subpages without + // destructive buttons (general/audit/telemetry/email/sso/branding) + // are deliberately excluded. + 'pages/admin/settings/account-access(default).phtml', + 'pages/admin/settings/user-lifecycle(default).phtml', 'pages/admin/settings/api(default).phtml', 'templates/partials/app-details-titlebar.phtml', 'templates/partials/app-details-aside-actions.phtml', diff --git a/tests/Architecture/DetailPageContractFiles.php b/tests/Architecture/DetailPageContractFiles.php index 148e019..242be1f 100644 --- a/tests/Architecture/DetailPageContractFiles.php +++ b/tests/Architecture/DetailPageContractFiles.php @@ -17,7 +17,10 @@ final class DetailPageContractFiles 'pages/admin/permissions/_form.phtml', 'pages/admin/scheduled-jobs/edit(default).phtml', 'pages/admin/settings/general(default).phtml', - 'pages/admin/settings/security(default).phtml', + 'pages/admin/settings/account-access(default).phtml', + 'pages/admin/settings/user-lifecycle(default).phtml', + 'pages/admin/settings/audit(default).phtml', + 'pages/admin/settings/telemetry(default).phtml', 'pages/admin/settings/email(default).phtml', 'pages/admin/settings/api(default).phtml', 'pages/admin/settings/sso(default).phtml',