Stabile Version – selbst gehostet, keine Abhängigkeiten, kein Cloud-Lock-in.
plenvo-latest.zip herunterladen565028a007f996ec32af39fea998143376e650dc071ba1240d4ec48778873358
Mit `sha256sum plenvo-latest.zip` lässt sich die Integrität prüfen.
Enthält Anforderungen, Einrichtung, Datenschutz- und Lizenzinformationen:
Sicherheits- und BSI-Compliance-Bericht des Codes:
Laden Sie das Archiv herunter und entpacken Sie es lokal.
Den entpackten Ordner per FTP/SSH in das Webverzeichnis Ihres PHP-Hosts hochladen.
Im Browser die installierte URL öffnen. Das Setup führt durch die Erstkonfiguration.
Plenvo: Your login code 123456. The external-user MFA mail (/ext/ token flow) already followed this pattern; the standard login MFA mail did not, and the two send paths are now consistent.JSON.parse: unexpected character error on subfolder installs (APP_BASE != ''). The output-buffer rewriter in views/layout/base.php and views/layout/external.php automatically prefixed every absolute href=, action=, src=, fetch('/, fetch("/, and location.href='/ with APP_BASE, but the three WebAuthn views (views/auth/login.php, views/landing/index.php, views/settings/totp.php) already include the prefix at render time via <?= APP_BASE ?>/webauthn/.... On a deployment at e.g. /mp/ the rewriter then doubled it to /mp/mp/webauthn/..., which is not a registered route, so Apache returned the HTML 404 page and the JS r.json() call exploded on the first byte. The rewriters now compare against the configured base before prefixing and skip URLs that are already prefixed. Root-level installs (APP_BASE = '') skip the rewriter entirely and were never affected.display_errors=On, breaking the WebAuthn registration flow (JSON.parse: unexpected character at line 1 column 1 of the JSON data) and any other JSON or redirect response in the same request. PHP 8.5 deprecates the magic $http_response_header variable, and three files were still reading from it after file_get_contents() calls (src/HealthCheck.php, src/Mailer.php, src/Updater.php). The deprecation notice was printed into the output buffer before session_start() and header() ran, cascading into "headers already sent" warnings that prevented Content-Type: application/json and the CSP header from ever being emitted. The three sites now prefer http_get_last_response_headers() (PHP 8.4+) and only fall back to the legacy magic variable on older PHP versions, where the deprecation does not exist. Production hosts running PHP 8.4 or earlier — or PHP 8.5 with the recommended display_errors=Off — were never visibly affected./webauthn/register-verify) now requires a valid CSRF token, accepted either as a csrf_token field inside the JSON body or as an X-CSRF-Token request header. SameSite=Lax session cookies and the browser's WebAuthn origin binding already blocked the obvious cross-origin paths, but the check brings the endpoint in line with /webauthn/delete and the rest of the state-changing API so that a missing token is rejected with 403 before any credential row is written. The settings page that triggers the registration ceremony has been updated to send the per-session CSRF token along with the attestation payload, so no user-visible change occurs.Content-Security-Policy-Report-Only to a fully enforced strict policy. Previously a duplicate enforced header set by the bundled .htaccess carried script-src 'self' 'unsafe-inline', which would have allowed any injected inline script to execute. The duplicate has been removed and the per-request policy in index.php is now the single source of truth: script-src 'self' 'nonce-<random>' 'strict-dynamic' without any unsafe-inline fallback for scripts. To make the enforced policy actually loadable, every inline <script> block in the views and bundled plugins now carries the request-scoped nonce, every external <script src=…> does too, and roughly 170 inline event handlers (onclick, onchange, oninput, onsubmit, onkeydown, hover styles, etc.) have been replaced by data-attribute hooks driven by a delegated dispatcher in assets/js/app.js. New attribute conventions cover the common patterns: data-click, data-change, data-input, data-keydown, data-submit resolve to a named window function; data-confirm and data-confirm-click show a confirm dialog before the action; data-stop-propagation, data-href, data-remove-parent, data-auto-submit, data-modal-open, data-modal-close, data-modal-backdrop, data-pw-toggle and data-select-on-focus cover the rest. The landing and external-token page layouts (views/layout/landing.php, views/layout/external.php) now also load assets/js/app.js, which is required for the dispatcher to resolve the new attribute hooks on those pages; previously those layouts shipped only their page-local inline scripts, so the WebAuthn login button on the landing page and the auto-submit on the external MFA-code input would have stopped reacting after the migration. A successful HTML injection can no longer smuggle in executable JavaScript through an attribute or a non-nonced <script> block. The style-src directive keeps 'unsafe-inline' because the views still ship inline style="…" attributes that CSP has no nonce mechanism for./^#[0-9a-fA-F]{6}$/ at write time via a new Helper::color() helper that returns a safe fallback for anything else. Previously the color values went through Helper::e() (HTML attribute escape) before being inserted into style="background:…", which is fine for HTML injection but leaves the CSS-value context unguarded. A project owner could store a value like red;background:url(https://attacker.example/log) so that every other member who opens the project's pages issues a request to a third-party host (privacy leak, tracking, defacement). The fallback applies at the four save points in controllers/ProjectController.php (project create, project edit, add status, add label); legacy rows in the database keep working because the validator only rejects values that fail the regex at write time and the views fall back to the existing default colors when a malformed value somehow shows up.Auth::BCRYPT_COST class constant. Every password_hash() call across src/Auth.php, controllers/AuthController.php, controllers/AdminController.php, controllers/SettingsController.php and controllers/ExternalController.php (registration, install bootstrap, admin user creation, admin password reset, password change, password reset via email token, email MFA codes, external MFA codes, the TOTP step-up code) reads from the same constant, including the timing-oracle dummy in Auth::dummyHash(), so login response time stays uniform between known and unknown accounts. Auth::login() now also calls password_needs_rehash() on every successful local password verification and rewrites the stored hash with the new cost in place, so existing user accounts migrate transparently at their next login without forcing a password reset. External-auth identities (LDAP and similar) are skipped because their stored password is a non-verifiable marker rather than a real bcrypt of the user's secret.manage.php (organizer results view) and public.php (public voting page) so the grid looks identical whether the viewer is the owner or an invited participant. Dark-mode overrides updated so the teal accent stays readable on dark themes.plugin.json description, the plugin's PHP-doc header, and the mp_plugin_desc localized string in all eleven supported languages have been rewritten to describe the feature on its own terms ("multi-option scheduling polls" / "Terminumfragen mit mehreren Optionen" / etc.).available_users_filter the rest of the app uses (so Provider Edition group isolation applies unchanged), arbitrary external addresses can be added in a free-text field, and every invitee gets the poll link by mail. Each invitation link carries a per-invitee token, so when an invited person opens it the poll page pre-fills their name and locks their e-mail address to the one they were invited at, which is how their response is matched back to the invitation; the manage page's invitee list then shows who has already responded, with per-invite resend (60-second cooldown) and remove actions. New routes /polls (owner area, behind the login gate), /polls/user-search (POST, CSRF-protected JSON type-ahead) and the public /poll/{token} registered via the plugin route hooks; a "Scheduling polls" sidebar entry shown to every signed-in user; five self-contained tables (mp_polls, mp_options, mp_responses, mp_votes, mp_invites) created idempotently on boot; all POST endpoints CSRF-protected and ownership-checked, every mutation is POST-only, all SQL is parameterized and all rendered values escaped; poll tokens and anonymous edit tokens are 128-bit CSPRNG values, the anonymous edit cookie is HttpOnly/SameSite=Lax/Secure and path-scoped to the poll, title and location are stripped of control characters before they reach mail subjects, free-text fields are length-capped and votes are validated against a fixed enum, a per-poll response ceiling together with a per-IP rate limit on newly created anonymous responses bounds abuse of the public link, and the plugin's PHP files refuse to run when requested directly instead of through the app router; public poll pages are marked noindex. The results grid and the standalone public voting page are styled with the app's theme CSS variables so they match every light and dark theme. All user-facing strings are localized in all eleven supported languages. The plugin ships enabled by default (it is not in PluginManager::DEFAULT_DISABLED), so it activates automatically on every instance after an update and can be switched off under /admin/plugins; its schema is created idempotently on boot, a failure during that migration is caught and logged rather than affecting the rest of the app, and register() is wrapped so a thrown exception can never break the host instance (at worst the poll routes are simply not registered).REMOTE_ADDR is malformed, instead of passing the invalid value through to security logs and lockout tracking.Fan-In · Fan-Out · N:M so the user can see at a glance which transition shapes the auto-builder produced./settings/totp now requires a fresh email step-up before a new authenticator can be added. The flow sends a 6-digit code to the account email (10-minute lifetime, max 5 attempts, one-minute resend cooldown) and unlocks the secret-generation and "enable" actions for 10 minutes once confirmed. Without a valid step-up the controller refuses both totp_generate and totp_enable, and the view never reveals the QR code or manual key. Closes a session-hijack escalation path where an attacker who steals only the session cookie of a user without TOTP could enroll their own authenticator and persist access through later password changes. Step-up send/verify/fail/lock events are recorded in the security log (totp_setup_step_up_sent, totp_setup_step_up_ok, totp_setup_step_up_failed, totp_setup_step_up_locked).ai_example_4 translation key shipped in all 11 supported languages.#0000aa background, white text and yellow active-state accents). The Profile theme select groups light themes first (Standard, Sepia, Forest, Crimson, Slate) followed by dark themes (Dark, Midnight, Ocean, Matrix, Bluescreen). The HTML root now carries an additional data-theme-mode attribute (light for the five light themes, dark for the five dark themes), and all dark-mode component overrides (alerts, badges, stat icons, status buttons, etc.) were migrated from [data-theme="dark"] to [data-theme-mode="dark"] so they apply uniformly to every dark theme. Theme value is now validated against an allowlist on save in SettingsController instead of being trusted blindly. Eight new translation keys (theme_sepia, theme_midnight, theme_ocean, theme_matrix, theme_forest, theme_crimson, theme_slate, theme_bluescreen) shipped in all 11 supported languages.set-theme API endpoint has been removed. The light theme is now treated as the default for all users; the theme_light translation across all 11 languages was renamed from "Hell"/"Light"/etc. to "Standard"/"Default"/etc. to reflect this. Existing user preferences for the dark theme are preserved./api/...) now enforces HTTP method and CSRF for every endpoint instead of only checking the CSRF token on POST. Mutating endpoints reject any request that is not POST with a 405 and require a valid CSRF token; a small allowlist (notifications/unread, notifications/recent) accepts GET for the read-only typeahead the topbar uses. Closes a CSRF-by-GET vector where a third-party page could trigger state-changing endpoints (e.g. notifications/delete-all, notifications/read-all) against a logged-in user via a simple cross-origin GET.pending_email_token / pending_email_expires columns alongside the already-excluded password reset and email verification tokens, drops the misleading login_attempts section (the table is keyed on IP for rate limiting and has no per-user data), and registers a shutdown hook that removes the temp ZIP file even when the client aborts the download mid-stream. Administrators get a per-row "GDPR Export" button on /admin/users that streams a ZIP archive with that user's personal data (profile, projects, tasks, comments, AI messages, notifications, activity log, security events, WebAuthn metadata, invitations, attachment metadata, group memberships). A README inside the archive lists the categories and reproduces the data subject's GDPR rights, with an operator-supplied paragraph at the top for the controller name and the data protection officer. Exports are built on the fly via tempnam and ZipArchive, streamed to the browser and removed immediately, so no copies linger on the server. Password hashes, MFA secrets, WebAuthn public-key bytes and encrypted mail bodies are deliberately excluded. Optional self-service path under /settings lets every logged-in user download their own archive when the operator enables it; per-user cooldown configurable in hours, attachment files optionally bundled. The self-export card renders on the user's profile page next to the existing 2FA and Delete-Account cards via a new settings_profile_extra hook, so users see it at the default settings landing page without having to navigate into a sub-page. Plugin ships disabled by default and is opt-in via /admin/plugins. Every export, settings change and throttle hit goes to the security log for audit purposes. README content is translated into all 11 supported languages so the user receives a localised data subject letter regardless of their account language./admin/settings. Counters for pending / given-up / oldest-pending-age render directly on the settings page; an "Open queue" button opens a modal with the full row list (id, state, recipient, subject, attempts, age, last error). The modal offers three clear-buttons: "Remove sent" (history only), "Clear given-up" (rows that hit MAIL_QUEUE_MAX_ATTEMPTS = 5), and "Clear all" (drops every row including pending). Each clear posts to the new POST /api/admin/mail-queue/clear endpoint with a confirmation prompt and writes a mail_queue_cleared SecurityLog event (SEV_WARN, mode and removed-row count). Modal data lives behind POST /api/admin/mail-queue/list (returns {stats, rows}); both endpoints are gated on Auth::requireAdmin. New Mailer::queueStats(), Mailer::queueList(int $limit = 200) and Mailer::clearQueue(string $mode) helpers expose the queue state to the controllers and the health monitor without raw SQL.mail_queue health check. Returns error when at least one row has been pending for ≥ 30 minutes (the cron worker is not draining the queue, so password resets and MFA codes are stuck) and the topbar light flips red. Returns warn when only given-up rows exist (worker drained but a target permanently failed). Returns ok for an empty queue or a fresh non-stuck backlog. Integrated into HealthCheck::runAll so the cron monitor pages an admin via the existing health-alert pipeline whenever the threshold is breached.#mailQueueCard.× remove button and a small + button that opens an AJAX type-ahead popover; the popover shows the project's full member roster on first open so no typing is required. New POST /api/tasks/toggle-assignee endpoint handles the add and remove operations, gated on Auth::canEditTask and validated against project_members / project_externals. The existing batch-edit form on /task/{id}/edit stays available unchanged.confirm_delete prompt and posts to the existing /task/{id}/edit action so permissions and the activity_log entry behave identically to the detail-page delete./admin/settings. Backups bundle a SQLite snapshot, the app key, avatars, uploads, plugin state and a manifest in a single ZIP, with optional AES-256-GCM passphrase protection (PBKDF2-SHA256, 600 000 iterations). Persisted backups land under data/backups/ with retention configurable per day count. Two-step restore flow with manifest review, source-instance peek, and atomic apply with rollback. Cron-driven scheduled backups (daily, weekly, monthly) with last-run and last-error reporting. Restore upload cap of 512 MB.Mailer::send no longer blocks the user's request on the SMTP round-trip. Web (non-CLI) callers persist the message to the new mail_queue table (id, to_addrs as JSON, subject, html_body, text_body, created_at, attempts, last_attempt_at, last_error, sent_at) and return immediately; the cron worker drains the queue every tick via the new Mailer::processQueue() (batch size 25, max 5 attempts per row, retention 7 days for sent rows). Bodies are encrypted at rest via Crypto::encrypt on insert and decrypted on dispatch so SMTP-bound content does not sit in plaintext in the queue. The queue insert is wrapped in try/catch with a synchronous fallback to Mailer::sendSync so an unwritable queue cannot silently swallow operationally-important mail. CLI callers, the cron worker itself, and time-critical MFA paths bypass the queue and use sendSync directly so dispatching does not just re-queue everything and login codes still arrive within seconds. New idempotent SQLite migration in Database::__construct creates the table and a (sent_at, attempts) index. cron.php calls Mailer::processQueue() first thing in the run, wrapped in try/catch so a transient SMTP failure cannot starve the rest of the tick. Inline assignee picker, task creation, status change, comment, mention, group invitation and password change notifications inherit the speedup automatically because they all funnel through Mailer::send./api/tasks/assignee-search accepts an optional project_id parameter that switches into a fast path mirroring the query TaskController::edit builds for the form's assignee picker: direct project_members JOIN users plus project_externals, bounded by the project and capped at 50 rows. Empty query is allowed in scoped mode and returns the full member roster, which is what the inline list-view picker uses on first open. The parameterless behaviour for /my-tasks and other callers is unchanged.×) control is rendered by Helper::assigneeChips($assignees, $size, $withRemove = true) so it is present from the first page render rather than only after an AJAX add.version field against a SemVer regex on both the fresh-fetch and cached paths, so a malformed upstream entry can no longer surface in the admin UI as the "available version".release.sh reads APP_VERSION directly from index.php via grep+sed and validates the result against a SemVer regex before producing the release ZIP, removing a fallback path that executed index.php and could capture stray output as the version string.. and adds ini and inc to the executable-extension blocklist.onclick="location.href=…" and the cell's onclick="event.stopPropagation()" (both bubble-phase) cannot suppress them. Adds, removes and the popover's outside-click close therefore respond reliably without ever navigating to the task detail page by accident.CSRF_TOKEN and BASE_URL at request time instead of capturing them at IIFE init time, with <meta name="csrf-token"> in <head> as a fallback. The bottom-of-body script in base.php defines those constants after the list view content has already been parsed; capturing them at init produced an empty token in the closure and the AJAX call landed on the server's CSRF check with a missing value.php_config short-circuits to ok under cron because the values it inspects describe web-SAPI behaviour. The manual /admin/health-check view remains the authoritative source. Removes false-positive WARN emails about expose_php and session.cookie_httponly.cron_tick plugin hook and InactiveUserCleanup::runIfDue() in defensive try/catch so a misbehaving plugin or a transient external failure cannot abort the rest of the run.unknown instead of frozen orange._bundled-plugins/ staging directory drained on first boot.Helper::resolveUniqueUsername helper, so @username mentions resolve unambiguously.plugins/ldap/ (default disabled, opt-in via /admin/plugins) provides transparent authentication against an LDAP or Active Directory server. TLS validation pinned to LDAP_OPT_X_TLS_DEMAND by default with try/allow/never only available behind explicit warnings in the form. CA upload runs openssl_x509_parse server-side, rejects expired certificates at upload time, and writes the file with permissions 0600 under data/. LDAP filter substitution always wraps the email through ldap_escape($email, '', LDAP_ESCAPE_FILTER). Bind credentials are stored as Crypto::encrypt(...) payloads in the settings table.login_ldap_success, login_ldap_failure (wrong password), login_ldap_denied (not found, disabled, or not in required group), login_ldap_error (connection or service-bind failure). JIT provisioning logs ldap_user_provisioned; sync deactivations log ldap_user_deactivated; CA upload/remove logs ldap_ca_uploaded and ldap_ca_removed; setting changes log ldap_settings_changed. Audit trail is sufficient to reconstruct who got in, when, and via which mechanism.AuthController::forgotPassword treats accounts with auth_provider != 'local' as if the email were unknown. No reset link is sent and the same generic success response is returned, preserving the no-enumeration property and avoiding misleading reset emails for directory-managed users.impersonate plugin's /admin/impersonate/{id} route requires POST with a valid CSRF token. The "Impersonate" button in the admin user table renders as a <form method="POST"> with the standard CSRF input, and the impersonation banner's "Back as admin" control follows the same pattern. Audit events impersonate_start and impersonate_stop continue to record every identity swap.app_url setting (with HTTP_HOST as the fallback when no app_url is configured). Pinning the RP id to operator-controlled configuration keeps credential binding stable across reverse-proxy topologies. No data migration required.Referrer-Policy: same-origin header on every response so token-bearing URLs (project-external /ext/{64-hex} links, password-reset and email-verify links, OIDC callback) are not sent in the Referer header to off-site resources. In-app navigation still sends the referer so server-side analytics on Plenvo's own pages keep working.GroupAdminController::reset_password) gates the target user on both global role and in-group role. Targets with users.role = 'admin' or group_members.role = 'admin', as well as self-resets, are refused with access_denied. Group admins manage member passwords only; co-admin and global-admin credentials are out of scope for this action.preferred_username / name / given_name+family_name) through the new OidcProvisioner::resolveUniqueUsername helper. The helper checks for collisions against any other user (excluding the row being updated, so re-login keeps a stable username when there is no real conflict) and appends a suffix like (2), (3), … until unique. Keeps Mailer::notifyMentioned resolving @[username] to the intended recipient regardless of what the IDP advertises.ContactController::submit routes its 90-second rate limit through Helper::clientIp() so it honours the operator's trust_proxy_headers setting and counts real visitors rather than the upstream proxy. Aligns with the rest of the app's IP handling.cron.php wraps the cron_tick plugin hook and InactiveUserCleanup::runIfDue() in defensive try/catch blocks. A misbehaving third-party plugin or a transient LDAP/network failure can no longer abort the whole cron run and starve the mailer reminders, overdue notifications, scheduled updates and health checks that come after.inactive_warning_sent_at reset was only fired on the no-MFA login branch. Users with TOTP, Email-MFA or WebAuthn who got the deletion warning and then logged in successfully would still be deleted at the end of the grace period because their warning timestamp stayed set. All five login-completion paths (Auth::login no-MFA, Auth::completeTotpLogin, Auth::completeEmailMfaLogin, the trusted-token branch and WebAuthnController::loginVerify) now reset the flag together with last_login = CURRENT_TIMESTAMP in the same UPDATE.AdminController::healthCheck also persists the fresh aggregate via HealthCheck::storeCronState() so a manual visit to the health-check page updates the topbar immediately, and the topbar treats any persisted state older than 24 hours as unknown (grey) so an off cron does not freeze a stale orange warning indefinitely.update_assignees action no longer fires the generic notifyAssigneesOnChange mail to unchanged assignees, and the bigger update flow excludes just-added users and externals from the "task changed" mail so they do not receive a duplicate ping after their dedicated assignment mail. Removed assignees keep getting the unassign mail as before.views/external/overview.php) showed raw HTML markup of task descriptions in the snippet column because mb_substr was applied to the rich-text storage value before htmlspecialchars, leaving fragments like <p> visible to externals. The view now strips tags, decodes entities and collapses whitespace before substring + escape, and hides the snippet entirely when the description has no plaintext content.ldap, impersonate) did not reach customer instances when they auto-updated from 1.13.0 to 1.14.0. The whitelist in Updater::perform only ships with 1.14.0+, so the legacy 1.13.0 updater that runs the actual file extraction still blanket-skipped every plugins/ path. Bootstrap fix: each release ZIP now also carries a copy of every shipped plugin under _bundled-plugins/<id>/ (a path no historical updater recognises). On first boot after the update, BundledPluginInstaller::run (called from both index.php and cron.php before PluginManager::load) copies the staging tree into plugins/ and then removes it. Idempotent and safe to retry on failure. From this release forward, customers updating from any earlier version receive official plugins on the very next boot regardless of which updater code path executed.u_11) instead of the username when an admin selected a user. Same gap on the AJAX type-ahead at /api/tasks/assignee-search: admins got zero matches because the SQL required project-membership overlap with the searcher, and admins typically have no project_members rows. Both MyTasksController::resolveAssignedDisplay and the AJAX endpoint now bypass the membership-overlap check for admins; the Provider Edition plugin filter still applies on top, so tenant isolation is preserved. Externals dropdown gets the same admin-bypass for cross-project visibility.users schema gains three columns via idempotent ALTERs in Database::__construct: auth_provider TEXT NOT NULL DEFAULT 'local', external_id TEXT, ldap_synced_at INTEGER, plus an index on (auth_provider, external_id) and a new inactive_warning_sent_at INTEGER. password stays NOT NULL; LDAP-managed accounts carry a non-verifiable marker so the bcrypt path is dead for them by construction.Auth::login runs a new auth_external_login filter before the local password_verify. Filter receives the email, password, and the matching local user row (or null). Returns either a user array (external auth succeeded, possibly JIT-provisioned) or null (fall through to local). Filter result is structurally indistinguishable from a successful local login, so MFA and session-stamping run unchanged.cron.php now loads PluginManager and Crypto, then fires a cron_tick hook after stamping cron_last_run. Plugins that need periodic work hook into this single point and throttle internally.ldap, impersonate) as part of the release: their files are extracted from the update ZIP and overwrite the previous version, so existing customer instances receive the LDAP plugin without any manual upload step. Everything else under plugins/ continues to be skipped, so admin-installed third-party plugins (custom branding, internal automation, etc.) are never overwritten. The whitelist lives in Updater::perform; adding a new official plugin means adding its directory id there AND shipping its folder in the release ZIP./admin/plugins. Implementation: PluginManager::DEFAULT_DISABLED now contains ['impersonate', 'ldap'], used both for the no-file boot path and a one-shot ensureBuiltinDefaults() migration that augments an existing plugins_disabled.json with any new built-in defaults. Guard marker plugin_builtin_defaults_v1 ensures the migration runs exactly once and a try/catch keeps it safe during /install before the settings table exists./admin/plugins) is now a single full-width card. The plugin upload moves to a primary button in the page header that opens a modal; the static "Plugin-Struktur" instructions block was removed (developers writing plugins look at the manifest of an existing plugin or the docs anyway).settings_url (validated to start with /admin/ and contain only [A-Za-z0-9/_-]) renders a "Settings" button next to "Deactivate" in the listing. description_key points to a central lang entry so the description renders in the operator's language; the listing falls back to the static description when no key is set, so third-party plugins keep working unchanged.plugins/ to customer instances except provider-edition (operator-specific custom-branding bundle that stays tied to a single deployment). New official plugins follow the app version automatically without any further code change. release.sh stages all non-blacklisted plugin directories under _bundled-plugins/ for the post-update bootstrap. To opt out a future plugin from auto-distribution, add its id to Updater::perform's $pluginBlacklist and to release.sh's PLUGIN_BLACKLIST variable.plugins/ldap/ provides transparent LDAP / Active Directory authentication. Single login form (no separate "Sign in with AD" button), the plugin is plumbed into the new auth_external_login filter that sits before the local password_verify. On successful directory bind the plugin JIT-provisions a users row with auth_provider='ldap', external_id set to the LDAP DN, and an unverifiable local password marker so the bcrypt path can never grant access for managed accounts. Re-evaluates the admin role on every login from the configured admin group DN; a configurable required-group DN can gate access entirely./admin/plugins/ldap covers connection (host/port/base-DN), service account (bind DN plus AES-256-GCM-encrypted bind password), filters and attribute mapping, behaviour (TLS validation level, takeover toggle for migrating existing local accounts), CA upload (PEM, max 32 KB, openssl_x509_parse-validated), test bind probe, and manual sync trigger. Three transport modes: LDAPS (default, port 636), LDAP plus StartTLS (port 389), and unencrypted LDAP. Cleartext requires an explicit acknowledgement checkbox; the health-check pipeline keeps reporting it as a security finding so it stays visible in compliance audits, but the email-alert pipeline silences once the admin has acknowledged it. Switching back to LDAPS or StartTLS clears the acknowledgement.LdapSync::runIfDue) wired into the new cron_tick plugin hook fired from cron.php. Hourly throttle inside the sync class so the hook is safe to fire on every tick. The sweep walks every active LDAP-managed user, repeats the configured user filter combined with the activity filter, and flips users to is_active=0 who no longer match the directory; LDAP search errors do not deactivate (avoid false positives on transient outages). Login-time blocking is independent of this sweep, because the same activity filter is applied at every login attempt.ldap_transport (error on cleartext, ok on encrypted), ldap_ca_expiry (warn from 30 days out, error after expiry, uses the validTo timestamp cached at upload time), ldap_sync (ok if the directory sync ran in the last 4 h, warn after 4 h, error after 24 h; ok also on fresh installs).ext-ldap as an optional extension. The check uses extension_loaded('ldap') and renders alongside cURL under the "Optional" section.app_version compares the APP_VERSION constant in index.php against the value persisted in settings.app_version. Returns ok when they match, warn when the database row is missing (Database constructor failed to write the INSERT OR REPLACE row, e.g. read-only filesystem), and error when the two strings disagree (which surfaces partial DB restores or a stale snapshot served by a stuck PHP-FPM worker that never re-ran the boot init)./admin/users is now sortable by clicking the column headers (Username, Email, Role, Group, Status, Last login). The whitelist of sort columns lives in the controller; nullable columns sort NULLs last regardless of direction.views/projects/list.php). Checkbox per row plus a header "select all visible" checkbox; a sticky toolbar appears when at least one task is selected and offers Status, Priority, Assignees (multi-select replace), Due date, Add label, Remove label, Delete. New API endpoint POST /api/tasks/bulk-update runs every action inside one DB transaction with a per-task Auth::canEditTask check; tasks that do not belong to the claimed project are silently dropped from the set. Activity log gets one entry per task; notifications are intentionally not fired by bulk operations to avoid email floods on sprint planning.Ctrl+K / Cmd+K, Mac auto-rewrites to ⌘+K) for cross-project quick-jump to projects, tasks, and people. New assets/js/cmdk.js plus modal markup and styles in views/layout/base.php; new API endpoint POST /api/search with min-length 2, debounced, abortable, scoped through project_members joins so non-admins only see what they may access. People results carry an "open tasks" counter and jump straight into the Tasks list with the matching assigned_to filter pre-applied (/my-tasks?assigned_to=u_<id>&status=open for users, e_<id> for externals). Counter query mirrors the Tasks-page filter exactly so the number on the cmd-k card equals the result count after clicking. Externals are restricted to non-archived projects so a person whose home project was archived no longer surfaces in the search at all. Topbar gets a search affordance that doubles as a keyboard-shortcut hint and opens the palette via a new window.openCmdK()./my-tasks, renamed from "My tasks") gets a fully reworked filter bar. The "Assigned to" filter is a server-side type-ahead picker (POST /api/tasks/assignee-search) that shows the static defaults (Me / Anyone / Unassigned) when empty and fetches matching users and externals (only from non-archived projects) once the user types two characters. Default filter is "Assigned to me". A "Clear filters" link drops every filter so project leaders see every task they have access to in one click. Permission floor is now the OR of project_members and task_assignees=me, so a user keeps seeing their own assignments after being removed from a project. Legacy ?scope=mine / ?scope=all URL parameters from dashboard cards still understood. New due filter (today / overdue) plus matching select in the bar; the resolver MyTasksController::resolveAssignedDisplay maps the active filter value (u_<id> / e_<id>) to the human label so the input shows the actual name instead of the URL token.#inactiveCleanupCard plus a matching sidebar sub-entry "Inactive accounts" under "System settings". Toggle (default off), inactivity threshold (default 12 months) and grace period (default 14 days). The cron sweep runs hourly: it sends a warning email to local-provider non-admin users whose last_login is older than the threshold, then deletes the account once the grace period passes without a login. Successful logins reset inactive_warning_sent_at so re-engaged users are out of the deletion queue. Projects where the user is the only project_members.role='owner' are deleted before the user; remaining projects fall through to the existing SettingsController::deleteUser admin-fallback logic. Admin accounts are never deleted to prevent self-lockout. New Mailer::sendInactiveAccountWarning, new src/InactiveUserCleanup.php, new audit events inactive_user_warned and inactive_user_deleted. LDAP-managed accounts and admins are filtered out by the SQL queries plus a defensive guard in deleteUserAndSoleOwnedProjects.notify_manager_assignees_changed (default off, separate from the coarse notify_manager_any_activity) lets the manager opt into pings for assignee churn alone. Wired into the project notifications form, persisted in project_notification_settings, fired from Mailer::notifyManagerAssigneesChanged whenever the assignee set actually changed./users/{id}/managed-tasks and /externals/{id}/managed-tasks group matching tasks by project, sorted by due-date asc (NULLs last) and priority desc; on permission mismatch they 404 (not 403) so id existence cannot be probed. New indexes idx_task_assignees_user and idx_task_assignees_external keep the people-counter cheap.plugins/oidc/ provides Single Sign-On via OpenID Connect Authorization Code Flow with PKCE (S256). Multi-provider design: a single plugin instance can host Keycloak, Google Workspace, Microsoft Entra ID and any other OIDC-compliant provider in parallel; each gets its own login button. Configuration page at /admin/plugins/oidc with quick-setup templates per provider kind that auto-derive the discovery URL from a realm URL (Keycloak), tenant id (Microsoft) or fixed endpoint (Google). Client secrets are stored as Crypto::encrypt(...) payloads. The flow validates the ID token's signature against the provider's JWKS via a self-contained RS256 verifier (no new Composer dependency, RS256 only allow-listed) plus the OIDC Core claim checks: iss exact match against discovery issuer, aud (with azp on multi-aud tokens), exp, iat freshness ≤10min, nbf with 30s clock-skew tolerance, nonce, signature. Discovery URL, token endpoint and JWKS endpoint are gated by an HTTPS-only policy; plain http:// only against the loopback names for local-test setups. The email_verified claim is honoured: providers that explicitly set it to false are rejected at JIT and at the takeover path. Discovery and JWKS are cached for 24 h / 1 h respectively; a signature-verify failure triggers exactly one JWKS refresh to handle key rotation. JIT-provisioning under auth_provider='oidc' with external_id formatted as <provider_id>:<sub> so multiple providers can coexist on the same account model. Optional takeover of existing local accounts mirrors the LDAP plugin. Plugin is shipped disabled by default and listed in PluginManager::DEFAULT_DISABLED; existing installs receive the new entry via the diff-based bootstrap migration that records applied default-disabled ids in plugin_builtin_defaults_applied_ids. Audit events: login_oidc_success, login_oidc_failure, login_oidc_denied, login_oidc_error, oidc_user_provisioned, oidc_user_takeover, oidc_provider_added, oidc_provider_updated, oidc_provider_removed, oidc_settings_changed. Login form gets a new login_form_extras plugin hook that renders SSO buttons under the password form; opt-in for any future plugin that wants to add login mechanisms./project/{id}/members queries a dedicated POST /api/project/available-users endpoint with a 2-character minimum and at most 10 results, debounced and abortable. The endpoint is gated by Auth::canEditProject (owner or manager on the project) and re-applies the available_users_filter plugin hook with the same isSafeWhereFragment allowlist that protects the host query, so Provider Edition group isolation continues to apply. Pages no longer ship the full active-user directory._renderList in assets/js/app.js builds notification items via the DOM API and writes title/message into textContent. Static SVG icons keep using a <template>-based parse path because their content originates in source code, not user input. Defense-in-depth so future renderer additions inherit the same structural property.Helper::baseUrl() honours X-Forwarded-Proto: https from the reverse proxy when the admin has enabled trust_proxy_headers. Server-generated links (password reset, email verify) keep the https:// scheme even when Plenvo terminates HTTP behind an HTTPS proxy.Crypto::decrypt logs to error_log and writes a crypto_decrypt_failed security event (severity critical) when an AES-256-GCM tag mismatch occurs. Surfaces ciphertext-handling failures (e.g. corrupted DB row, key file rotated) to admins through the standard logging pipeline.UPDATE ... WHERE id=? AND token=? and abort if rowCount() === 0, making consumption atomic for parallel requests. Touched: Auth::completeEmailMfaLogin, AuthController::verifyEmail, AuthController::resetPassword, AuthController::verifyEmailChange. The reset path also stamps password_changed_at so other live sessions are killed exactly as they are after a self-service password change.Auth::activateEmailMfaFromEnforce calls session_regenerate_id(true) on the transition from MFA-enforce-pending to email-MFA-pending, matching every other MFA-state transition in Auth.available_users_filter plugin hook in ProjectController::members validates the plugin-supplied SQL fragment via isSafeWhereFragment() before interpolating it into the host query. The allowlist accepts column-name tokens, ? placeholders, comparison/IN/IS NULL operators, parentheses and AND/OR; quotes, semicolons and comment delimiters are rejected.AuthController::install) acquires an exclusive flock on a plenvo_install.lock file in the system temp dir before running schema creation, and re-checks DB_PATH after acquiring the lock. Concurrency-safe under parallel POSTs.ContactController::submit strips CR / LF / NUL bytes and clamps length to 100 characters from the user-supplied name before passing it to PHPMailer::addReplyTo. PHPMailer guards against header injection itself; this is defence-in-depth./settings are committed only after the user clicks a verification link sent to the NEW address. The old address simultaneously receives a heads-up notification that an email change was requested. New columns users.pending_email, users.pending_email_token, users.pending_email_expires; new route /verify-email-change; new mailer methods sendEmailChangeVerify and sendEmailChangeNotice. The pending change is shown back to the user in the profile form.users.password_changed_at is set on every successful change; the session-validation block in index.php compares it against the session's _login_time and forces a logout when the session is older. The session that performs the change re-stamps _login_time (and regenerates its session ID) so the changing user stays logged in. The user is notified by email (Mailer::notifyPasswordChanged).trust_proxy_headers (default off) gates whether X-Forwarded-For is honoured for client-IP detection. SecurityLog and Auth route through a single Helper::clientIp() and respect the toggle, producing consistent IP attribution behind a reverse proxy. UI exposed under *Admin → Settings → Security → Reverse-proxy headers*.Auth::login runs password_verify against a dummy bcrypt hash even when no user with the submitted email exists, keeping the response time uniform across known and unknown emails.verify_email_sent flash regardless of whether the email is already taken, mirroring the existing forgotPassword behaviour for unified responses./settings/totp requires the account password in addition to the second factor. Each disable is written to the security log (mfa_disable, severity warn). New translation key current_password_label.WebAuthnController::loginVerify shares the IP rate limiter with the password-login flow, and WebAuthnController::delete writes a webauthn_delete security-log entry. Credential lifecycle is fully audit-logged.10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), IPv4 link-local (169.254.0.0/16, including the cloud-metadata service), IPv6 link-local (fe80::/10) and IPv6 unique-local (fc00::/7). Loopback (127.0.0.0/8 and ::1) stays allowed because Ollama's default deployment listens there. cURL is pinned to the IP we already validated via CURLOPT_RESOLVE, defeating DNS-rebinding races between the host check and curl_exec.Updater::perform skips plugins/ during ZIP extraction so a manifest-server release does not overwrite admin-installed plugins. The path-traversal check rejects entry names containing .. or NUL bytes outright, and a final post-mkdir realpath check on the resolved parent stays as a belt-and-braces guard.ContactController::submit stamps the per-IP rate-limit file at the start of validation (after CSRF and honeypot pass), not after a successful send. Consistent enforcement across success and failure paths.data/database.sqlite with 0640 permissions (owner rw, group r, world none) instead of relying on the umask default. SQLite WAL/SHM sidecar files are auto-tightened from world-readable to 0640 on every boot to keep them in step with the main DB. Existing installs are not stomped on; only the brand-new file gets chmod'd, so any operator-set tighter perms (e.g. 0600) are preserved.Helper::sanitizeHtml with symfony/html-sanitizer ^6.4 (production-hardened, mutation-XSS-resistant, actively maintained). Allowlist preserved (same tags + attributes as before). One intentional behaviour change: data: URIs in <img src> are not accepted at all; users who need to embed images go through the attachment upload pipeline (which has its own MIME validation). Validated against 13 fuzzing inputs (XSS payloads, broken markup, edge cases).assets/js/app.js: data-click / data-change / data-input / data-keydown / data-confirm / data-submit attributes are centrally dispatched, so new views need no inline on*= attributes. The 152 existing inline handlers across 21 views are migrated iteratively per file with browser test. Only integer DB IDs are interpolated into the remaining inline handlers; no user-controlled strings.security_events table (via migration 002_security_events.php and Database::schema() per the dual-source convention). New src/SecurityLog.php with log($type, $severity, $details, $userId) helper that captures the client IP, never throws, and a pruneOlderThan($days) method for retention enforcement. Hooks added at every authentication and privileged-action path: Auth::login (password success/failure/lockout), all four MFA-completion paths, WebAuthnController::loginVerify, Auth::recordMfaFailure, Auth::recordAccountFailure, SettingsController (password change), AdminController (user create/delete/admin password reset, plugin enable/disable/delete/upload), Updater::perform, ImpersonatePlugin::start/stop. New admin view /admin/security-log with filter by event type and severity (info/warn/critical badges), capped at 500 most recent rows, sidebar entry under the admin nav. Retention configurable in admin Security policies (security_log_retention_days, default 365, 0 = keep forever); cron prunes older entries on each tick. 19 new lang keys per locale.ImpersonatePlugin::start() and stop() call session_regenerate_id(true) before swapping $_SESSION['user_id']. Session id is rotated on every identity transition.PluginManager::load() validates the manifest class field against ^[A-Za-z_][A-Za-z0-9_]*$ before passing it into require_once. Defence-in-depth on top of the upload pipeline's executable-payload rejection.error_log calls via the new Helper::maskEmail() helper (alice@example.com → a***@example.com). DSGVO-friendly: log lines stay useful for correlation without persisting the full identifier.mail_save_test_* lang keys removed from all 11 locales.composer.json). New required extensions: dom (used by Symfony HtmlSanitizer), zip (used by Updater::perform() for ZIP extraction). The previously "optional" zip is now required because auto-updates would otherwise fail silently. curl stays optional because the HIBP check and AI provider integrations both have stream-wrapper fallbacks. README requirements section expanded with brief per-extension rationale.overflow-x: clip fix applied to .main-wrapper for the topbar already enables sticky-bottom for the footer in the same parent context./admin/settings) now has a single floating "Save all settings" button (bottom-right, sticky). When JS is enabled, it submits all five settings forms (App, System, Security, Mail SMTP, AI) sequentially via fetch and shows a success toast + reloads the page on completion. The five per-block "Save" buttons are hidden when JS is active and stay as a graceful-degradation fallback. The Domain Languages CRUD card and the SMTP Test/MS Graph Test buttons keep their own forms because they perform discrete actions, not bulk settings save. Four new lang keys per locale (save_all_btn, save_all_in_progress, save_all_success, save_all_failed).Lang::load() no longer hardcodes ['de', 'en']; it now validates against Lang::SUPPORTED and falls back to Lang::FALLBACK (en) for unknown codes. SettingsController::profile() validates submitted user language against the same list.Helper::getSetting no longer poisons its in-memory cache with the default value when the setting row is absent. Different callers passing different defaults each get the correct fallback. Helper::setSetting invalidates the cache so a same-request read returns the freshly written value instead of the stale one.ApiController::updateTaskStatus and updateTaskPosition reject status_id values that belong to a different project. Prevents data corruption from cross-project status assignment.cron.php acquires an exclusive flock on data/cron.lock before doing any work and exits silently if another run is still active. Prevents duplicated reminders, racing settings writes and overlapping auto-update installs when the previous tick has not finished yet.cron.php extracts APP_VERSION via regex over the whole index.php file instead of reading line 7 verbatim. Robust against future repositioning of the constant.SettingsController::profile shows an explicit error message when an admin tries to delete their own account (admin_cannot_self_delete) instead of being a silent no-op.UPDATE in a try/catch and surfaces the email_taken error if a parallel request grabs the same address between the SELECT and the UPDATE. Closes a TOCTOU window that previously crashed with a raw UNIQUE-constraint violation.renderAiText in views/projects/ai.php) extracts fenced code blocks and inline code into placeholders before applying inline-emphasis transforms, so *text* inside backticks is no longer rewritten to <em> mid-render. List handling rewritten as a line-by-line state machine so consecutive bullets form one <ul> while blank lines correctly start a new list, instead of all <li> elements collapsing into a single list across the whole message.Crypto::key() opens data/app.key with LOCK_EX and re-checks the file contents under the lock before generating a new key. The new file is created with 0600 perms via umask(0177) so there is no world-readable window between file_put_contents and chmod.ExternalController passes null instead of 0 for user_id when writing external activity to activity_log. The dummy 0 produced misleading JOIN results because no real user has that id.Updater::changelogBodyToHtml renders ` code , **bold** and *italic*` inline-markdown inside changelog headings and bullets. Previously these characters appeared literally in the in-app update modal.auto_update_window_minutes, default 240). If cron is offline through the slot and the window has already passed, the day is marked as done and the update waits for tomorrow's slot. Prevents unexpected installs at unrelated times of day after a missed slot.Auth::completeEmailMfaLogin enforces a per-code attempt counter in the email_mfa_codes table (new column attempts, default 0). After MFA_MAX_ATTEMPTS wrong guesses the code is marked used_at regardless of which session is guessing. The per-session counter remains as the first line of defence.Database::schema() (where it sat behind $this-> in a static context) into the Database constructor where it actually runs. SQL filter NOT LIKE 'enc1:%' ensures the loop is a DB-level no-op on every boot once migration has completed for an installation..main-wrapper from overflow-x: hidden to overflow-x: clip for the same horizontal-overflow protection without breaking sticky positioning./admin/settings): health_check_cron_enabled (toggle, default off), health_check_interval_minutes (5–1440, default 60), health_check_alert_email (comma-separated list; empty falls back to all active admins). New src/HealthCheck.php extracts the previously inline check logic from AdminController::healthCheck() into a reusable static runAll() (the AJAX endpoint now delegates to it). Cron runs the checks at the configured interval, persists the per-check state in settings.health_check_last_state, and only emails on transitions: OK → warn/error or warn → error sends an alert; warn/error → OK sends a "resolved" notice. Each problem fires exactly once per status change, never on every tick. New Mailer method sendHealthAlert() formats the issue list with severity badges in the recipient's language and timezone. Twelve new lang keys per locale (hc_cron_*, mail_hc_*)./admin/settings#healthCheckCard. Visible to admins only; cheap to render (one settings read per page load). Four new lang keys (topbar_health_ok/warn/error/unknown)./admin/settings → App Settings (next to the default language picker, new setting app_default_timezone, default UTC). Each user can override it in their profile (users.timezone column added via migration 003_users_timezone.php; empty value inherits the admin default). PHP's date_default_timezone_set() is called at boot from the resolved timezone (logged-in user > admin default > UTC), so date()/strtotime() everywhere produce correct local times. New helpers Helper::currentTimezone(), Helper::timezoneForUser($user), Helper::isValidTimezone($tz). Helper::formatDate() and formatDateTime() accept an optional explicit timezone parameter and treat DB DATETIME values as UTC (matching SQLite's CURRENT_TIMESTAMP). Mailer's admin notifications (notifyAdminsGroupRequest, notifyAdminsNewRegistration) now render the "Time" field in each recipient admin's own timezone instead of the server's. Timezone picker is a flat sorted IANA list from \DateTimeZone::listIdentifiers(). Five new lang keys per locale (timezone_label, timezone_user_hint, timezone_inherit_default, app_default_timezone_label, app_default_timezone_hint).src/Migrator.php + migrations/ directory). New schema work is delivered as files like migrations/NNN_description.php returning function (PDO $pdo): void. The Migrator records applied ids in applied_migrations and runs only unapplied ones in lexical order, each in its own transaction. Trigger points: web boot in index.php, cron run in cron.php, and post-extraction step in Updater::perform(). Legacy idempotent CREATE TABLE IF NOT EXISTS / try { ALTER TABLE ... } catch blocks in Database::__construct() remain untouched for backward compatibility; only new schema work goes through the migrator.lang/ and are listed in the new Lang::SUPPORTED constant alongside Lang::NAMES for native picker labels.Accept-Language header with q-values (Lang::fromAcceptHeader()) and picks the highest-priority match against the supported list, falling back to Lang::FALLBACK (en) when nothing matches.app_default_language setting). It is consulted when both browser and personal language fail to resolve a supported language./admin/settings. New domain_languages table created via migration 001_domain_languages.php. Mailer uses Helper::languageForEmail() to resolve the recipient's language (override > domain mapping > admin default > fallback) and switches Lang for the duration of an outbound external email.finfo MIME sniffing, never persisted to disk, and forwarded as an email attachment via PHPMailer::addAttachment(). Filename is sanitized.auto_update_enabled and set auto_update_time (HH:MM, server time) under System Info & Updates. The cron checks once per day after the configured time and, if a newer version is available and updates_enabled is on, runs Updater::perform() and records the date in auto_update_last_run so it does not re-trigger on every cron tick.settings.migration_last_error, the Health Check card on /admin/settings shows a red "Database migrations" entry with the failing id and message, and a yellow entry when files are pending. Migrator::pendingCount() and Migrator::lastError() expose this state to other consumers. Five new lang keys per locale (hc_migrations*).src/Mailer.php are fully localized in the recipient's preferred language (resolved via Helper::languageForEmail()): notifyTaskUpdate, notifyAssignment, notifyManagerActivity, notifyManagerTaskDone, notifyPredecessorsDone, sendReminders, notifyAssigneesOnComment, notifyAssigneesOnChange, notifyAssigneesOnUnassign, notifyAssigneesOnAttachment, notifyManagerTaskCreated, notifyMentioned, sendDueDayReminders, sendOverdueNotifications, sendStartDateNotifications, notifyAdminsGroupRequest, notifyAdminsNewRegistration. New private Mailer::sendLocalized($email, $compose) helper wraps the Lang switch with try/finally so the request's original language is restored even if a send throws.mail_* language keys per locale (× 11 locales = 671 translations) covering every notification subject, body, link label, in-app fallback message, and admin email field labels.is_active = 0 AND email_verify_token IS NOT NULL AND created_at < (now − N hours). New admin setting unverified_user_max_hours (default 48, range 0–8760, 0 disables the cleanup) under App Settings. Each deletion writes a unverified_user_purged event (info severity) to the security log with the masked email. Reuses the existing SettingsController::deleteUser() cascade so all related rows go away cleanly.notify_admins_new_registration (default 1) under App Settings. When unchecked, Mailer::notifyAdminsNewRegistration() returns early — neither the email nor the in-app notification fire. Independent of the global notifications toggle, so admins can keep all other notifications on while silencing just registration noise.Database::schema() (the declarative target schema used by the install wizard) includes domain_languages and applied_migrations, and at the very end calls Migrator::markAllApplied() to record every existing migration id without executing its body. Effect: fresh installs do not redundantly re-run historical CREATE/ALTER chains; their applied_migrations table is filled by a single bulk INSERT OR IGNORE so future incremental migrations have a correct baseline. Existing installs are unaffected. New convention: every new table/column goes into BOTH Database::schema() (for fresh installs) and a versioned migration file (for upgrade catchup); both must remain idempotent so a drifted instance self-heals.totp_secret field and always use the server-stored secret. Pending pre-MFA sessions cannot influence the secret in use. Affected: controllers/AuthController.php (totp_enable_forced, totp_enable_mfa).users.totp_last_counter, and any code whose counter is less than or equal to the stored value is rejected. New helper Auth::verifyTotpCode() is used by login, forced setup, MFA enforce setup, and TOTP enable/disable in settings.users.failed_login_count and users.locked_until columns. Counter resets on every successful authentication, password reset, or admin password reset._login_time and _last_activity in the session./logout are redirected to the dashboard. The sidebar logout link is a form accordingly.users.reset_token / users.email_verify_token. Lookups hash the incoming value and compare. A one-time migration nullifies any pre-existing plaintext tokens./%2F%2Fevil.com).hash_equals for constant-time comparison and validates the checksum format before downloading./api/avatar/{id} requires authentication. Adds X-Content-Type-Options: nosniff, Content-Disposition: inline, and Cache-Control: private to the response. File path is resolved from the database (not via glob()) and the extension allowlist is enforced..php, .phtml, .phar, .htaccess, .htpasswd, etc.).POST /api/task/add-dependency validates that predecessor and successor belong to the same project and that the caller can edit it.http/https only, link-local addresses (169.254.0.0/16, including the cloud-metadata service at 169.254.169.254) are blocked, redirects are disabled, and the cURL protocol whitelist is restricted to HTTP/HTTPS. Localhost remains allowed for local Ollama deployments.csp_nonce() helper) and a strict Content-Security-Policy-Report-Only header without 'unsafe-inline' for script-src. Admins can monitor browser DevTools to see which inline scripts and event handlers would break before flipping the enforced policy. The enforced policy still keeps 'unsafe-inline' for backward compatibility; removing it requires a follow-up to refactor inline event handlers across views.onclick attributes. The list uses data-notif-action attributes and a single delegated click handler.src attributes (only safe raster formats are permitted) and rejects data: URIs in href attributes.rel="noopener noreferrer" on any anchor element with a target attribute./settings, views/settings/profile.php) stacks all sections (Profile Info, Change Password, Two-Factor Authentication, Delete Account) in a single vertical column. Layout matches the admin system settings page so the visual rhythm is consistent across both areas./settings or on the 2FA detail page /settings/totp. Clicking a sub-entry scrolls smoothly to the corresponding card. The "Profile" parent stays highlighted across the whole context.plugins/groups/ to plugins/provider-edition/. Plugin id changed from groups to provider-edition to keep the directory name and id aligned. Footer detection (PluginManager::isEnabled('provider-edition')) and the release-ZIP exclusion in release.sh updated accordingly. The class file GroupsPlugin.php and class name remain unchanged.plugin_can_disable and plugin_can_delete. When either returns false the corresponding button is rendered as a greyed-out disabled element with an explanatory tooltip, and the server-side action in AdminController::plugins() rejects the operation with a localized error. Enforced both in the rendered UI and on the POST handler./admin/settings) is labelled "Notifications active (email + in-app)" with an explicit hint describing both states: when active, in-app notifications are always produced and emails are additionally sent if a mail provider is configured; when inactive, no notifications of either kind are created regardless of mail configuration.plugins/groups/plugin.json. The directory move follows in 1.10.0.views/layout/base.php appends " Provider Edition" to the version string (e.g. "Plenvo v1.9.0 Provider Edition") when the groups plugin is installed and enabled. Plain "Plenvo vX.Y.Z" remains for the customer/standalone edition./admin/settings) lists Health Check first, followed by System Info & Updates, before the remaining sections (App Settings, Security policies, Mail, AI). Surfaces operational status checks and pending updates above configuration.plenvo.gp-server.com/downloads/#changelog. The downloads page scrolls smoothly to the changelog section when opened via the #changelog anchor.plugins/provider-edition/) and is not part of the customer platform. Without the plugin, every user can see every other user and project leaders can add any platform member to a project directly.PluginManager::addFilter() and PluginManager::filter() for data-filtering hooks, and PluginManager::addPublicDynamicRoute() for routes that must be reachable without authentication.release.sh.views/settings/totp.php). Navigation back to the profile is still available via the existing arrow link in the page header.APP_VERSION from the new index.php on disk and comparing it to the target version. If they do not match, the update is marked as failed with a clear error message prompting to check directory permissions.[Unreleased] block (labelled with the target version from the update server) when no matching versioned entry exists in the local file, so changelog entries for an upcoming release are still shown in admin Software Update.preg_split with PREG_SPLIT_DELIM_CAPTURE silently drops unmatched optional capture groups, shifting all array offsets when [Unreleased] has no date; and the en-dash character in version headers was not matched because the regex lacked the /u UTF-8 flag. Switched to preg_match_all with PREG_OFFSET_CAPTURE and added the /u modifier to fix both issues.getSignatureCounter() instead of getSignCount()).b64uEncode($data->credentialId) call.APP_BASE) so a deployment under e.g. /mp correctly suggests https://example.com/mp instead of https://example.com.t() escapes HTML. Switched to tr() for those strings.plugin_can_disable and plugin_can_delete filters that return false for its own id. Once an on-prem installation activates Provider Edition, there is no UI path back to the standalone edition. The lock policy lives entirely inside the plugin and disappears together with it; the core only provides the hooks.plugin_lock_active_hint, plugin_lock_active_error (de + en)..btn:disabled / .btn[disabled] CSS rule (opacity .5, cursor: not-allowed) so any disabled button site-wide gets consistent visual treatment.notifications_enabled_hint, mail_save_test_subject, mail_save_test_body, mail_save_test_ok, mail_save_test_failed (de + en).warning flash channel rendered on the admin settings page, used by the post-save mail verification./admin/settings), the sidebar expands a list of section anchors (Health Check, System Info, App Settings, Security, Mail, AI) underneath "System Settings". Clicking a sub-entry scrolls smoothly to the corresponding card.PluginManager::isEnabled(string $id) helper that returns true only when the plugin manifest exists on disk and the id is not in the disabled list. Used by the footer to detect the Provider Edition bundle./admin/settings) exposes the lockout threshold/duration, idle timeout, absolute session lifetime, and the HIBP toggle. New save_security POST action in AdminController::settings..htaccess file presence.src/PasswordPolicy.php class with validate() and pwnedCount() helpers.login_account_locked, session_expired, password_pwned, mfa_attempts_exceeded, and the sec_* / security_settings keys for the new admin panel (de + en).