# Changelog

All notable changes to Plenvo are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Versioning follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`

---

## [Planned]
Roadmap items intended for future releases. Not yet implemented; scope and timing may change.
- Nextcloud integration (plugin)

## [Unreleased]

## [1.20.2] – 2026-05-15
### Changed
- The email MFA code is now appended to the email subject in addition to the body, so users can read the 6-digit code straight from a mobile push notification or inbox preview without opening the message. The subject becomes e.g. `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.

### Fixed
- WebAuthn registration and login API calls returned a `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.
- API endpoints could return a corrupted body that started with rendered PHP warning HTML instead of the expected JSON on hosts running PHP 8.5 with `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.

### Changed
- The WebAuthn registration verify endpoint (`/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.
- The Content-Security-Policy has been switched from `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.
- User-supplied colors for projects, task statuses, and labels are now validated against `/^#[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.
- Bcrypt password hashing now uses an explicit work factor of 12 instead of the PHP default of 10, configured via a new `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.

## [1.19.1] – 2026-05-13
### Changed
- Scheduling polls (Terminfindung) results grid restyled to match the landing-page preview. Vote cells render as solid dots (yes / maybe / no = ● / ? / ○) instead of the previous ✓ / (✓) / · glyphs, with consistent colors that hold up across all ten themes. The leading-option column is highlighted with a soft teal vertical gradient and a small ★ marker in the column header, replacing the previous indigo box-shadow accent. The finalized-option pill switches from the success-green badge to the same teal-to-cyan gradient used in the landing CTA. The totals row uses the new dot glyphs for yes/maybe counts. The same treatment is applied to both `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.
- Scheduling polls plugin description no longer references a third-party scheduling product by name. The `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.).

## [1.19.0] – 2026-05-12
### Added
- New built-in plugin "Scheduling polls" (Terminfindung) that brings poll-based meeting scheduling to Plenvo. Logged-in users create a poll with a title, optional description and location, an optional response deadline and a list of date/time options that are picked by clicking days in a built-in month calendar; a default-time control with quick presets (all-day, 08:00, 09:00, …) plus a default duration (15 min … 8 h, or open end) is applied to each newly picked day so the end time fills in automatically, an "apply to all" button pushes the current default onto every already-picked day, and any individual day can still be fine-tuned or set all-day. They then get a public share link. Anyone with the link votes Yes / If-need-be / No per option after entering their name; no Plenvo account is needed, and a per-poll cookie lets anonymous participants come back and change their answer later. Logged-in users vote with their account (one response each, name taken from their profile). The owner watches a live results grid on a manage page where the slot with the most "yes" votes is highlighted, can delete individual responses, edit the poll and its options, and finalize the winning slot, optionally e-mailing every participant the confirmed time. An optional "hidden poll" mode stops participants from seeing each other's answers, and the owner can opt in to an e-mail on every new response (delivered through the existing mail queue). From the manage page the owner can invite participants by e-mail: a type-ahead picks Plenvo users through the same `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).

## [1.18.2] – 2026-05-07
### Added
- Honeypot field on the registration form to silently absorb bot signups without creating a user row or sending a verification email; triggered submissions are recorded in the security log.

### Changed
- Admin notification for new registrations is now sent only after the user has confirmed their email address, preventing notification spam from unverified signup attempts.

### Fixed
- Client IP detection now returns null when `REMOTE_ADDR` is malformed, instead of passing the invalid value through to security logs and lockout tracking.
- Kanban drag and drop now restores a card to its exact original position when the position-update API call fails, instead of always appending it to the end of the source column.
- Comment deletion no longer triggers a PHP warning when the parent task has been deleted; non-author requests are now denied cleanly with a 403 in that edge case.

## [1.18.1] – 2026-05-05
### Fixed
- Gantt auto-link builder rewritten so projects with parallel tracks are detected from task dates instead of title keywords. The previous implementation classified tasks into eight software-lifecycle phases by matching keywords like "test" or "konzept" in the title; the matcher hit false positives (e.g. "Test" inside "Testlizenz" landed in the test phase) and a fan-in rule attached every prior task to the next sequential successor, producing 18 edges for a 13-task procurement project. The classifier and the date-based fallback have been replaced by one date-overlap phase grouper: tasks whose windows transitively overlap form one phase, and a task starting on or after the latest end of the current phase opens a new one. Tasks inside the same phase stay parallel with no edges between them. At each phase boundary the builder picks one of four cases. (1) Single successor — fan-in: every current-phase task becomes a predecessor of that one milestone, so convergence points like "ADV-Vertrag" really do wait on every parallel input. (2) Single predecessor — fan-out: that one task becomes the predecessor of every task in the next phase, so a parallel block branches off cleanly from a single bridge task. (3) N:M — both phases have multiple parallel tasks and the conservative dependency is the full bipartite link, every current-phase task to every next-phase task; this fixes the previous "viele → viele wird vergessen" bug where only the latest-ending current-phase task got linked to the next phase, leaving every other parallel predecessor without an outgoing edge. (4) 1:1 sequential. No task is left isolated: every phase past the first has its tasks linked to the previous phase. The toast now reports the breakdown as `Fan-In · Fan-Out · N:M` so the user can see at a glance which transition shapes the auto-builder produced.
- Gantt dependency rendering routes fan-in, fan-out and N:M matrix groups through a shared vertical "bus" placed strictly between the predecessor end and the successor start, and the merged section past the bus is drawn thicker (2.5 px vs 1.5 px on the per-edge branches) so the converged part of the line reads as one heavier "Y" trunk. Previously each branch dropped its vertical at the per-branch x value (predecessor right edge plus 12 px), so when six parallel predecessors all converged on a single milestone the six vertical drops landed inside the other parallel bars on the same date band, producing a tangle that visually sliced through every neighbouring bar. The router now computes the bus x as the rightmost predecessor end plus a 15 px margin (capped against the successor start so the arrowhead still has clearance), which guarantees that the shared vertical never overlaps any predecessor bar. Fan-in groups draw N thin branches converging at the bus, then one thicker segment with the only arrowhead carrying into the successor; fan-out groups draw a symmetric thicker trunk from the predecessor to the bus, then N thin branches each carrying their own arrowhead to a successor. N:M groups (a complete bipartite K_{n,m} subgraph between two parallel blocks, detected by grouping successors that share the exact same predecessor set) get a single shared bus too: N thin pred-side feeders into the bus, one thick vertical bus spanning every involved row, and M thin succ-side branches with their own arrowheads, so the N×M edges read as one "Y" joiner instead of M independent fan-ins each carrying its own bus. Single 1-to-1 dependencies still take the existing direct elbow. Edge cases where the calendar gap collapses to zero (predecessor ends on the same day the successor starts) fall back to the per-branch elbow, since no bus position can fit cleanly there.

### Security
- TOTP enrollment under `/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`).

## [1.18.0] – 2026-04-30
### Added
- Project AI chat empty-state gains a fourth example chip "Stelle mir nacheinander Fragen zu diesem Projekt, um mir die passenden Aufgaben zu liefern" so users can kick off a guided dialogue mode where the assistant asks clarifying questions one at a time before generating tasks. New `ai_example_4` translation key shipped in all 11 supported languages.
- Project list view shows a new "Start date" column between "Assigned to" and "Due date", matching the existing date format. Default sort order changed from "priority then due date" to "start date ascending with NULLs last" so the list reads in calendar order matching the Gantt timeline.
- Project list view column headers are now clickable to change the sort. Title, Status, Priority, Start date and Due date are sortable; clicking a header sets it as the active sort, clicking it again toggles between ascending and descending. Active column shows ▲ (asc) or ▼ (desc). The chosen sort survives in the URL alongside any active filters and search term so the order is preserved on reload and bookmarkable. Server-side sort/dir parameters are matched against an allowlist before being interpolated into the ORDER BY clause.
- Eight new colour themes selectable from Profile → Theme on top of the existing Light/Dark pair: Sepia (warm, paper-like light theme that is easier on the eyes during long sessions), Forest (light content with a forest-green sidebar), Crimson (light content with a burgundy sidebar), Slate (light content with a steel-grey sidebar and sky-blue accents), Midnight (deep near-black with indigo accents), Ocean (dimmed blue-tinted dark theme that is softer than full dark), Matrix (pure black with phosphor-green text and subtle green glow shadows) and Bluescreen (the classic Windows BSOD aesthetic with deep `#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.

### Changed
- Theme toggle button removed from the top bar. The dark-mode option is now exclusively configurable per user under Profile settings, and the previously available `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.

### Fixed
- Gantt auto-link builder no longer collapses non-software projects into a star topology where every task points at the last row. The keyword classifier now tracks how many task titles were recognised as a software lifecycle phase, and when fewer than 30 % match (typical for renovation, event-planning or procurement projects, where almost everything ends up in the implementation catch-all bucket and used to fan-in into a single closing row), the builder switches to a date-based chain mode. Date mode sorts tasks by start, end and original list position, then attaches each task to the latest-ending earlier tasks whose windows do not overlap with the current start. Parallel tasks (same start, or overlapping windows) stay unconnected to each other; fan-ins emerge naturally when multiple parallel tasks all finish at the same time before a sequential successor begins. Software projects with recognisable phases keep the existing keyword-based DAG builder.

### Security
- API dispatcher (`/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.

## [1.17.1] – 2026-04-29
### Added
- Security log: per-column filter row under the table header (Time, Level, Type, User, IP, Details). Filtering happens client-side as the admin types or picks a severity, so refining the visible 500-row window is instant. The pre-existing server-side type and severity dropdowns above the table remain so admins can still pull older entries past the 500-row cap.
- New built-in plugin "GDPR Data Export" that fulfils the right of access under Art. 15 GDPR. The exporter excludes the hashed `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.
- New "Mail queue" card under `/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.
- New `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.
- Sidebar submenu under "System Settings" gains a "Mail queue" anchor jumping to `#mailQueueCard`.
- Project list view: assignees can be added or removed inline on a row without opening the task detail page. The assignee cell renders chips with an `×` 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.
- Kanban cards gain a discreet delete button in the top-right corner. The icon stays invisible by default, fades in to half-opacity on card hover, and goes solid red on direct hover. Clicking it opens the standard `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.
- New "Backup & Restore" card under `/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.
- Default backup passphrase setting: store an encrypted passphrase once and every backup operation, manual or scheduled, uses it automatically.
- Pre-update backup: the auto-updater creates a fresh restorable backup as the last step before any destructive write. Both the manual one-click update and the cron auto-update slot inherit this behaviour.
- Gantt diagram: fan-in merge for tasks with multiple predecessors. All incoming arrows converge at a shared point about 15 px before the successor and continue as a single line with one arrowhead into the bar. A small junction dot at the convergence point makes the bundling visually obvious.
- Gantt diagram: phase-aware auto-link builder. Tasks are classified by keyword into project phases (analysis, concept, prototype, development, integration/test, bugfix, deployment, closing). The resulting dependency graph is a DAG instead of a linear chain. Parallel implementation tracks such as Backend and Frontend stay parallel, and convergence phases like integration tests pick up every preceding task automatically.
- Gantt diagram: hovering a task bar highlights every dependency arrow attached to it in blue, making chains easy to trace at a glance.

### Changed
- `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.
- Project list view assignee chips stack vertically inside the cell so multiple assignees stay readable instead of crowding into a single row, and the chip remove (`×`) 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.
- Updater download path streams to disk and prefers cURL with TLS verification pinned and redirects disabled, with a stream-wrapper fallback that mirrors the same posture. Manifest fetch capped at 64 KB; download size capped at 100 MB. Status line is checked upfront so non-200 responses are rejected before SHA verification. Manifest parser additionally validates the `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.
- Backup operations raise the worker memory limit upward (4× file size plus 64 MB headroom) so AES-GCM stays within reach under typical Debian defaults.
- Gantt bar label shows only the task title; the assignee suffix was redundant with the sidebar list.
- Project member role assignments are owner-only. Managers retain add and remove operations but cannot change role-bearing assignments.
- Plugin upload pipeline rejects ZIP entries whose basename starts with `.` and adds `ini` and `inc` to the executable-extension blocklist.

### Fixed
- Inline-assignee-picker click handlers on the project list view register on the document in capture phase so the row's `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.
- Inline assignee picker reads `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.
- Health-check `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 wraps the `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.
- Inactive-account cleanup correctly resets the warning timestamp on every login completion path (no-MFA, TOTP, Email-MFA, trusted-token, WebAuthn) so users who logged in during the grace period keep their account.
- Topbar health-check indicator updates immediately after a manual refresh and treats persisted state older than 24 hours as `unknown` instead of frozen orange.
- Assignee-change notifications no longer ping unchanged assignees and the "task changed" mail is no longer duplicated to just-added recipients.
- External-portal task overview strips raw HTML from description snippets so externals see clean preview text.
- Project member type-ahead and assignee search work correctly for admins.
- Bundled official plugins reach instances upgrading from older versions through a `_bundled-plugins/` staging directory drained on first boot.
- Gantt dependency arrows render reliably above task bars and approach the successor horizontally from the left at every zoom level. Same-day deps use a backtrack route so the arrow never enters the successor through its top or bottom edge.
- Gantt dependency arrowheads always render. Markers are now built in the explicit SVG namespace so a browser quirk cannot drop the triangle.
- Provider Edition: group-scoped user search returns the correct empty set for accounts not yet assigned to a group across the project-member picker, the Cmd+K people search, the assignee picker, and the managed-tasks filter.
- Provider Edition: group-admin role updates and password resets are symmetric. Neither path can target the caller themselves, a global admin, or a co-admin in the same group.
- Provider Edition: usernames created via the group-admin "create user" form or the invitation-acceptance flow are guaranteed unique through the new `Helper::resolveUniqueUsername` helper, so `@username` mentions resolve unambiguously.

## [1.15.1] – 2026-04-28
### Security
- New plugin `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.
- LDAP plugin writes audit events for every login path with masked email: `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.
- The `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.
- WebAuthn relying-party id is derived from the admin-configured `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.
- New `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.
- Provider Edition group-admin password reset (`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.
- OIDC re-login, account takeover and JIT provisioning route the IDP-supplied username (`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.

### Fixed
- `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-account cleanup: the `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.
- Topbar health-check traffic light kept showing the last cron-stored aggregate even after the underlying issue had been fixed and re-checked manually. Two-stage fix: `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.
- Assignee-change notifications now reach only the directly affected people. The `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.
- External-portal task overview (`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.
- Bundled official plugins (`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.
- Tasks-page assigned-to filter showed the raw URL token (e.g. `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.

### Changed
- `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.
- Auto-updater treats officially bundled plugins (`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.
- LDAP plugin ships disabled by default and requires an explicit admin opt-in via `/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 page (`/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).
- Plugin manifests gain two optional fields. `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.
- Plugin distribution policy switched from a hand-maintained whitelist to a blacklist. The auto-updater ships every directory under `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.

### Added
- New plugin `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.
- LDAP plugin configuration page at `/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.
- Cron-driven directory sync (`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.
- Three new health checks, registered only when the LDAP plugin is enabled: `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).
- Installer prerequisites page lists `ext-ldap` as an optional extension. The check uses `extension_loaded('ldap')` and renders alongside cURL under the "Optional" section.
- Health check `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 user list at `/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.
- Bulk-edit toolbar in the project list view (`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.
- Command palette (`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()`.
- Tasks page (`/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.
- Dashboard stat cards (Active projects, My open tasks, Due today, Overdue) are now clickable links into the matching list views. Counter queries are aligned with the Tasks-page query so the number on the card equals the list rendered after clicking it.
- Admin can auto-delete long-inactive accounts. New settings card with anchor `#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`.
- New project setting `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.
- Two new bookmarkable detail routes `/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.
- Many new lang keys per locale across the new features: health-check, LDAP, Inactive-cleanup, command palette, Tasks-page filters, plugin descriptions and bulk-edit. Per-locale string count is documented in the corresponding feature commits.
- New plugin `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.

## [1.13.0] – 2026-04-27
### Security
- The "Add member" form on `/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.
- All token-consuming flows use a conditional `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.
- Installer (`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.
- Email-address changes via `/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.
- Password changes invalidate every other live session for the same user. New column `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`).
- New admin setting `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.
- Self-service registration returns the same `verify_email_sent` flash regardless of whether the email is already taken, mirroring the existing `forgotPassword` behaviour for unified responses.
- Disabling TOTP or email-MFA in `/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.
- AI SSRF guard blocks RFC1918 ranges (`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.
- Installer creates `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.
- Replaced the in-house DOMDocument-based `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).
- Delegated-handler infrastructure in `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.
- Dedicated security event log (BSI APP.3.1.A14, DSGVO Art. 32). New `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.
- Email addresses are masked in `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.

### Removed
- Auto-test email after saving SMTP settings (introduced in 1.9.0). The implicit test was triggered every time mail settings were saved, including via the new "Save all" button, sending an unwanted test mail on every settings save. The "Test SMTP" / "Test MS Graph" buttons in the Mail card already provide explicit testing on demand. Four now-unused `mail_save_test_*` lang keys removed from all 11 locales.

### Changed
- Install-wizard prerequisites check brought up to date with the current code's actual dependencies. PHP minimum bumped from 8.0 → 8.1 (matches `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.
- App footer (version + Plenvo link + privacy/imprint) is now sticky-pinned to the viewport bottom while scrolling, mirroring the sticky-top topbar. Solid background prevents content from showing through. Same `overflow-x: clip` fix applied to `.main-wrapper` for the topbar already enables sticky-bottom for the footer in the same parent context.
- System settings page (`/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.
- Profile language picker now lists all 11 supported languages with their native names instead of only Deutsch/English.

### Fixed
- `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.
- Self-service account deletion in `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.
- Profile email update wraps the `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.
- AI chat client-side renderer (`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.
- Scheduled auto-update only fires inside a configurable window after the configured slot (new setting `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.
- TOTP plaintext-to-AES-256-GCM migration moved from the static `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.
- Topbar (avatar / notifications / theme toggle) stays pinned to the viewport top while scrolling. Switched `.main-wrapper` from `overflow-x: hidden` to `overflow-x: clip` for the same horizontal-overflow protection without breaking sticky positioning.

### Added
- Eight additional health checks following the operator brainstorm:
  1. **Sensitive path exposure** — HTTP-probes `/src/`, `/controllers/`, `/vendor/`, `/lang/`, `/config/`, `/migrations/`, `/data/app.key` against `app_url`. Lists each path that returns 200 (should be 403/404 thanks to `.htaccess`). Single combined check `paths_exposed`.
  2. **`data/app.key` permissions** — must be `0600`; tighter perms ensure the AES-256-GCM key file is filesystem-isolated.
  3. **`data/database.sqlite` permissions** — fails when world-readable (`& 0007 != 0`).
  4. **HTTPS-only enforcement** — verifies `app_url` starts with `https://` AND probes the `http://` variant (must redirect or block, anything but 200).
  5. **Admin accounts without 2FA** — counts active admins where `totp_enabled=0 AND email_mfa_enabled=0` AND no `webauthn_credentials` row. Warns when > 0.
  7. **PHP runtime configuration** — flags `display_errors=On`, `expose_php=On`, `session.cookie_httponly=Off`. Combined into one check listing the violations.
  12. **Mail heartbeat** — `mail_last_success_ts` setting written by `Mailer::recordHeartbeat()` after every successful `send()` / `sendViaLocal()` / `sendViaMsGraph()`. Warns when last successful send is older than 7 days while notifications are enabled and a provider is configured. Catches silently broken SMTP.
  14. **Free disk space** — `disk_free_space(DATA_PATH)`; reports current free GB. Errors below 15 GB threshold (uploads, backups, SQLite WAL all need headroom).
  Eight new check IDs surface in `/admin/security-log` events through the existing cron alert pipeline (state-deduped). Every new check has lang keys × 11 locales.
- Cron-driven health check monitor with state-deduped email alerts. Three new admin settings under the Health Check card (`/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_*`).
- Topbar health-status traffic light for admins. Renders a colored dot (green/yellow/red/grey-unknown) computed from the last cron-stored state, with a tooltip describing the state. Click navigates to `/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`).
- Timezone configuration. Admins set a system-wide default under `/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`).
- Versioned database migration system (`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.
- Nine new UI languages: Spanish (es), French (fr), Hungarian (hu), Italian (it), Dutch (nl), Polish (pl), Russian (ru), Turkish (tr), Chinese Simplified (zh). All language files live in `lang/` and are listed in the new `Lang::SUPPORTED` constant alongside `Lang::NAMES` for native picker labels.
- Browser language detection now parses the full `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.
- Admin can configure a system-wide default language under App Settings (`app_default_language` setting). It is consulted when both browser and personal language fail to resolve a supported language.
- Admin can map email domains to languages under the new "Domain language mapping" card on `/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.
- Contact form on the landing page accepts an optional file attachment (jpg/png/gif/webp/pdf, max 10 MB). The file is validated server-side via `finfo` MIME sniffing, never persisted to disk, and forwarded as an email attachment via `PHPMailer::addAttachment()`. Filename is sanitized.
- Scheduled automatic updates: admins can enable `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.
- Sidebar submenu under "System Settings" gained two new anchors: "Domain language mapping" and "Scheduled automatic updates".
- Twenty-eight new language keys per locale (× 11 locales) covering external-assignment emails, default language settings, domain mapping UI, contact attachment, and scheduled updates.
- Migration failure surfacing: failed migrations are persisted in `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*`).
- All remaining outbound-mail methods in `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.
- 61 new `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.
- Localization sweep: removed all remaining hardcoded German user-facing strings. Touched email wrapper footer, Updater admin in-app notification, plugin enable/disable flash messages, preferences-saved flash, four project status/label flashes, AI request failure error, and the contact-form admin email. Operator-only artefacts left in German on purpose: cron CLI output and the AI system prompt itself. 18 new lang keys per locale (× 11 locales = 198 translations).
- Auto-deletion of unverified registrations: cron removes users with `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.
- Admin toggle for new-registration notifications: new setting `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.
- Hybrid migration model (snapshot + incremental): `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.

## [1.10.1] – 2026-04-26
### Security
- Forced TOTP setup and MFA enforce setup ignore any client-supplied `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`).
- TOTP replay protection: successfully consumed counters are stored in `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.
- TOTP secrets are encrypted at rest using AES-256-GCM. Write paths (setup, forced setup, MFA enrollment) encrypt before storing; read paths decrypt transparently. Existing plaintext secrets are migrated automatically on first boot.
- Per-account login lockout. After a configurable number of failed password attempts (default 5) the account is locked for a configurable duration (default 15 minutes). Accounts with any second factor configured (TOTP, Email-MFA, or WebAuthn) are exempt because 2FA already mitigates password-only abuse. New `users.failed_login_count` and `users.locked_until` columns. Counter resets on every successful authentication, password reset, or admin password reset.
- MFA brute-force protection per pending session. After five failed TOTP or email-MFA code submissions the pending session is destroyed and the user is sent back to the login page. Applies to TOTP login verify, email MFA verify, forced TOTP setup, and MFA enforce setup.
- Failed MFA code attempts (TOTP and email-MFA) increment the IP-based rate-limit counter shared with the password-login flow.
- External portal MFA codes track failed verification attempts; after 5 consecutive failures the code is invalidated and a fresh one must be requested. Resending a code is rate-limited to once per 60 seconds.
- WebAuthn login blocks accounts with unverified email addresses, consistent with the password login flow.
- Accounts with unverified email count as a failed login attempt toward the IP rate limiter, keeping the response shape identical for known/unknown addresses.
- Configurable session timeouts. Idle timeout (default 30 minutes) and absolute lifetime (default 8 hours) end sessions automatically. Both can be set to 0 to disable. Login completion paths (regular, TOTP, Email-MFA, MFA-enforce, WebAuthn, impersonation) stamp `_login_time` and `_last_activity` in the session.
- CSRF verification on the login POST endpoint.
- CSRF verification on all MFA POST endpoints: TOTP verify, email MFA verify, forced TOTP setup, and MFA method selection setup.
- Logout requires POST with a valid CSRF token; GET requests to `/logout` are redirected to the dashboard. The sidebar logout link is a form accordingly.
- Password reset and email verification tokens are stored as SHA-256 hashes at rest. The raw token is sent in the email, the hash is persisted in `users.reset_token` / `users.email_verify_token`. Lookups hash the incoming value and compare. A one-time migration nullifies any pre-existing plaintext tokens.
- Login redirect target validation also blocks percent-encoded protocol-relative URLs (e.g. `/%2F%2Fevil.com`).
- Optional Pwned Passwords (HIBP) check via the k-anonymity range API. Only the first 5 hex chars of the SHA-1 hash leave the server. Applied to register, password reset, profile password change, admin user create, admin password reset, and invite acceptance. Disabled by default; admins can enable it under Security policies.
- Updater requires a valid SHA-256 checksum in the manifest before installing a package. The check uses `hash_equals` for constant-time comparison and validates the checksum format before downloading.
- Avatar endpoint `/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.
- Avatar initials in fallback avatars are HTML-escaped.
- File upload MIME check is performed on the temporary file before moving it to disk, eliminating the TOCTOU window between move and read.
- Plugin ZIP extraction skips files with executable or server-configuration extensions (`.php`, `.phtml`, `.phar`, `.htaccess`, `.htpasswd`, etc.).
- Admin user-edit modal exposes only the fields needed for editing in the JSON payload; sensitive columns (password hash, TOTP secret, reset tokens) are not included.
- Cross-project task dependencies are rejected. `POST /api/task/add-dependency` validates that predecessor and successor belong to the same project and that the caller can edit it.
- AI assistant endpoint enforces a per-user rate limit of 30 messages per hour.
- SSRF hardening for AI provider requests. URLs are validated to `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.
- Per-request CSP nonce mechanism (`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.
- Notifications dropdown rendering does not interpolate user data into inline `onclick` attributes. The list uses `data-notif-action` attributes and a single delegated click handler.
- HTML sanitizer blocks SVG data URIs in image `src` attributes (only safe raster formats are permitted) and rejects `data:` URIs in `href` attributes.
- HTML sanitizer forces `rel="noopener noreferrer"` on any anchor element with a `target` attribute.

### Changed
- Profile page (`/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.
- Sidebar shows in-page section anchors (My Profile, Change Password, 2FA, Delete Account) underneath the "Profile" entry while a non-admin user is on `/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.
- Renamed the Provider Edition plugin directory from `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.
- Admin plugins page consults two new filters before allowing a plugin to be deactivated or deleted: `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.
- Notifications toggle on the system settings page (`/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.
- Saving the mail settings triggers a real verification email to the current admin's address using the just-saved configuration. On success the admin sees a confirmation that the test email was sent; on failure the settings are still saved but a clear warning is displayed so the configuration can be corrected. Skipped automatically when no mail provider is configured or the admin has no email address on file.
- Renamed the bundled "Groups & Invitations" plugin display name to "Provider Edition" in `plugins/groups/plugin.json`. The directory move follows in 1.10.0.
- App footer in `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.
- System settings page (`/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.
- Renamed "Systeminformationen" to "Software Update" in admin system settings.
- Replaced the inline changelog diff in admin Software Update with a "View changelog" link pointing to `plenvo.gp-server.com/downloads/#changelog`. The downloads page scrolls smoothly to the changelog section when opened via the `#changelog` anchor.
- "Allow password reset" checkbox in admin settings is disabled and visually grayed out when no mail transport is configured (SMTP, Microsoft Graph, or local sendmail). The hint text says "mail" instead of "SMTP" to cover all mail providers.
- Replaced the security key login button icon with a classic key icon (circle grip, angled blade with notches) that is more recognizable as access/authentication.
- Removed the version number from the auth layout footer.
- Multi-tenancy (group-based user isolation, group management, invitations, group admin routes) is delivered as the separate **Provider Edition** plugin (`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.
- Added `PluginManager::addFilter()` and `PluginManager::filter()` for data-filtering hooks, and `PluginManager::addPublicDynamicRoute()` for routes that must be reachable without authentication.
- The Provider Edition plugin is excluded from the customer release ZIP via `release.sh`.

### Fixed
- Removed the duplicate "← Mein Profil" back-link at the bottom of the MFA settings page (`views/settings/totp.php`). Navigation back to the profile is still available via the existing arrow link in the page header.
- Updater counts failed file writes (e.g. due to permission errors) and reports them in the update log instead of silently ignoring them.
- Updater verifies the installed version after extraction by reading `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.
- Updater falls back to the `[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.
- Changelog entries in admin Software Update settings were not displayed due to two parser bugs: `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.
- WebAuthn login: corrected library method call (`getSignatureCounter()` instead of `getSignCount()`).
- WebAuthn key registration: corrected `b64uEncode($data->credentialId)` call.
- Installer pre-fills the server URL with the subfolder path (`APP_BASE`) so a deployment under e.g. `/mp` correctly suggests `https://example.com/mp` instead of `https://example.com`.
- Plugin upload hint and structure hint rendered raw HTML tags as text because `t()` escapes HTML. Switched to `tr()` for those strings.
- Member autocomplete dropdown in project settings scrolled the page instead of the list. Added a wheel event handler that scrolls the dropdown directly and prevents page scroll propagation. Dropdown shows exactly 3 entries at a time (38 px each).
- Fixed an undefined variable bug in the file upload handler that caused the stored MIME type to be empty.

### Added
- Provider Edition plugin registers `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.
- Language keys `plugin_lock_active_hint`, `plugin_lock_active_error` (de + en).
- Generic `.btn:disabled` / `.btn[disabled]` CSS rule (opacity .5, `cursor: not-allowed`) so any disabled button site-wide gets consistent visual treatment.
- Language keys `notifications_enabled_hint`, `mail_save_test_subject`, `mail_save_test_body`, `mail_save_test_ok`, `mail_save_test_failed` (de + en).
- New `warning` flash channel rendered on the admin settings page, used by the post-save mail verification.
- When viewing the admin settings page (`/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.
- New `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.
- Project management with Kanban board, Gantt chart, and list view.
- Task management with priorities, due dates, labels, comments, file attachments, and multi-assignee support.
- Task dependencies (predecessors and successors) with automatic Gantt linking.
- External collaborators: guests can view and update their tasks via a personal link without an account.
- Role-based access control per project (owner, manager, member, guest).
- Activity log per project.
- Cron-based reminders and overdue notifications.
- Two-factor authentication via TOTP authenticator app and email code; both methods can be active simultaneously.
- MFA enforcement option for administrators.
- Password reset via email.
- Email verification on registration.
- WebAuthn / FIDO2 security key support (YubiKey and other FIDO2 keys). Users can register one or more keys in Settings → Security and then sign in from the login page with a single key touch, no password required. Uses discoverable credentials so the key identifies the user automatically. Sign count is verified on every use to detect cloned keys.
- SMTP email notifications with granular per-project settings (assignments, comments, due date reminders, predecessor completion).
- Microsoft 365 (OAuth 2.0 client credentials via Graph API) as an alternative mail provider.
- Local sendmail as a third mail provider option for server-side delivery without an external mail server.
- Admin panel: user management, group management, system settings, AI configuration, plugin management.
- Admin settings panel "Security policies" (`/admin/settings`) exposes the lockout threshold/duration, idle timeout, absolute session lifetime, and the HIBP toggle. New `save_security` POST action in `AdminController::settings`.
- Admin system settings shows changelog entries between the installed version and the available update version, parsed directly from CHANGELOG.md. The list is only visible when an update is available.
- Admin notification when a new version is available (once per version, on settings page load).
- Auto-updater: one-click update in system settings with progress console, SHA-256 checksum verification, and automatic database migrations.
- Option to disable automatic update checks and all outbound connections to plenvo.gp-server.com.
- Health Check card in system settings: monitors cron job activity and `.htaccess` file presence.
- Administrators see a "Support" link at the bottom of the sidebar navigation that opens the Plenvo support contact page in a new tab.
- Multilingual UI (German and English) with automatic browser language detection.
- Light and dark theme per user.
- Member search in project settings uses an autocomplete text field instead of a plain dropdown. Users can search by name or email, navigate results with arrow keys, and confirm with Enter.
- Landing page with feature overview, hosting options, FAQ, and contact form.
- AI assistant supporting Anthropic Claude, OpenAI, Azure OpenAI, and Ollama for task generation and project planning.
- Plugin system with upload, install, enable, and disable support.
- Install wizard with system requirements check (PHP version, extensions, write permissions) before setup.
- New `src/PasswordPolicy.php` class with `validate()` and `pwnedCount()` helpers.
- Language keys `login_account_locked`, `session_expired`, `password_pwned`, `mfa_attempts_exceeded`, and the `sec_*` / `security_settings` keys for the new admin panel (de + en).
- AGPL v3 license.

---

## [1.5.0-beta] – 2026-04-25
### Added
- Initial public beta release.
