|
|
@@ -0,0 +1,258 @@
|
|
|
+# Changelog
|
|
|
+
|
|
|
+All notable changes to this project are documented here. The format is based
|
|
|
+on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
|
+adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
+
|
|
|
+## [Unreleased]
|
|
|
+
|
|
|
+Nothing scheduled.
|
|
|
+
|
|
|
+## [0.22.0] — 2026-05-07
|
|
|
+
|
|
|
+First tagged release. The minor version mirrors the latest build phase
|
|
|
+shipped (Phase 22). Section grouping below reflects the build-phase history
|
|
|
+captured in `SPEC.md` §9 plus the security-review hardening pass tracked in
|
|
|
+`doc/REVIEW_01.md` (R01-N01..R01-N34).
|
|
|
+
|
|
|
+### Added
|
|
|
+
|
|
|
+- **Phase 1 — Skeleton.** Front controller, router, request/response
|
|
|
+ primitives, view layer.
|
|
|
+- **Phase 2 — Auth.** Microsoft Entra ID via OpenID Connect (Authorization
|
|
|
+ Code + PKCE), session + CSRF, first-user-is-admin bootstrap, optional
|
|
|
+ local-admin fallback for dev / on-prem deployments.
|
|
|
+- **Phase 3 — Workers, sprints, audit.** Worker CRUD, sprint create,
|
|
|
+ per-cell audit log.
|
|
|
+- **Phase 4 — Sprint settings.** Sprint metadata edit, weeks resize,
|
|
|
+ worker membership add/remove/reorder, per-row reserve-time-buffer (RTB).
|
|
|
+- **Phase 5 — Arbeitstage grid.** Editable working-days matrix, capacity
|
|
|
+ calculation, per-cell persistence with audit.
|
|
|
+- **Phase 6 — Task list.** CRUD, assignments grid, sort/filter/search,
|
|
|
+ drag-reorder.
|
|
|
+- **Phase 7 — Audit viewer + polish.** `/audit` admin page with filters,
|
|
|
+ pagination, collapsible diffs; security headers + strict-ish CSP; CSRF
|
|
|
+ audit (18/18 mutations); PHPUnit harness with 59 tests.
|
|
|
+- **Phase 8 — Cascade audit integrity.** Three FK cascade paths
|
|
|
+ (sprint_worker → sprint_worker_days, sprint_worker → task_assignments,
|
|
|
+ sprint_week → sprint_worker_days on shrink) snapshot-audit before the
|
|
|
+ parent delete fires.
|
|
|
+- **Phase 9 — Users management.** `GET /users` + `POST /users/{id}` with
|
|
|
+ self-demote and last-admin guardrails.
|
|
|
+- **Phase 10 — Task list polish.** Multi-select owner filter and column
|
|
|
+ visibility toggle, both pure client-side with localStorage persistence.
|
|
|
+- **Phase 11 — CSP hardening.** Vendored Tailwind via a Node css-builder
|
|
|
+ Docker stage; inline `onclick` replaced by `data-href` + `app.js`; CSP
|
|
|
+ dropped `unsafe-inline` and the Tailwind CDN host.
|
|
|
+- **Phase 12 — Per-week weekday selection (Mo–Fr) drives Arbeitstage.**
|
|
|
+ `sprint_weeks.active_days_mask` becomes the source of truth;
|
|
|
+ `max_working_days` is a cached `popcount(mask)` projection. Five
|
|
|
+ checkboxes per week in Sprint Settings; row of five dots per week in
|
|
|
+ the sprint view (green = active, gray = off). Migration 002 backfills
|
|
|
+ legacy rows.
|
|
|
+- **Phase 13 — Focus filter + Reset in the task list.** New
|
|
|
+ `[data-focus-select]` picks one sprint worker; `applyFocusColumnVisibility`
|
|
|
+ hides all-zero columns; `[data-reset-filters]` wipes search, prio,
|
|
|
+ ownerFilterSet, focusWorker, and hiddenCols in one click. State persists
|
|
|
+ in localStorage.
|
|
|
+- **Phase 14 — Hamburger menu.** Workers / Users / Audit log / Sign out
|
|
|
+ collapsed into a dropdown behind a `data-menu-trigger` button with an
|
|
|
+ inline-SVG icon; vanilla-JS toggle (~30 lines) honours outside-click,
|
|
|
+ Escape, and focus return.
|
|
|
+- **Phase 15 — Big-screen (beamer) task viewer.** New signed-in route
|
|
|
+ `/sprints/{id}/present` renders a stripped-down view for projection;
|
|
|
+ shared partial with `/sprints/{id}` via a `loadSprintPage` helper.
|
|
|
+ Auto vertical column headers when overflow detected.
|
|
|
+- **Phase 16 — Dark-mode toggle + light-mode contrast cleanup.** Manual
|
|
|
+ toggle (no `prefers-color-scheme` auto-detect); `theme-init.js`
|
|
|
+ synchronously sets `<html class="dark">` from localStorage to prevent
|
|
|
+ FOUC; comprehensive `dark:` class sweep across every view.
|
|
|
+- **Phase 17 — Number-stepper popover (later replaced).** Hidden native
|
|
|
+ number-input spinners (`@layer base` reset); per-cell click-to-open
|
|
|
+ vertical-slider popover on day, RTB, and task-assignment inputs. After
|
|
|
+ seven iterations the implementation was removed in favour of plain
|
|
|
+ typed entry; ArrowUp/ArrowDown stepping retained via browser default.
|
|
|
+- **Phase 18 — Per-cell task-status colours + filter + global toggle.**
|
|
|
+ `task_assignments.status` ∈ {`zugewiesen`, `gestartet`, `abgeschlossen`,
|
|
|
+ `abgebrochen`}; new `app_settings` KV table with opt-in
|
|
|
+ `task_status_enabled` flag; admin `/settings` page; Status multi-select
|
|
|
+ filter; first non-admin write surface (`PATCH
|
|
|
+ /tasks/{id}/assignments/status`). Migration 003.
|
|
|
+- **Phase 19 — Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS;
|
|
|
+ jQuery removed.** Stack-shift of the entire UI layer with zero changes
|
|
|
+ to controllers, repositories, schema, capacity math, or audit
|
|
|
+ semantics. All 11 `views/*.php` rewritten as `*.twig`. Strict CSP:
|
|
|
+ `script-src 'self'` and `style-src 'self'` only — no third-party
|
|
|
+ hosts.
|
|
|
+- **Phase 20 — XLSX import wizard.** Two-step admin-only wizard at
|
|
|
+ `/sprints/import`; multipart upload (≤ 5 MB, ZIP magic-byte check),
|
|
|
+ preview screen with target picker (Create new / Merge into empty
|
|
|
+ existing), per-sheet skip toggle, transactional commit; per-sprint
|
|
|
+ `IMPORTED_FROM_XLSX` audit row. New service trio:
|
|
|
+ `XlsxColorClassifier`, `XlsxSprintImporter`, `SprintImporter`.
|
|
|
+ Composer dep `phpoffice/phpspreadsheet ^3.4`; runtime stage adds
|
|
|
+ `zip` + `gd` extensions.
|
|
|
+- **Phase 21 — Auto-derived week count in sprint settings.** `PATCH
|
|
|
+ /sprints/{id}` resyncs `sprint_weeks` whenever `start_date` or
|
|
|
+ `end_date` changes (target = `floor((end − start)/7) + 1`, capped at
|
|
|
+ 26). Existing rows realign; appended rows default to `MASK_ALL`;
|
|
|
+ trailing rows shrink with the same audit-cascaded-days flow as the
|
|
|
+ legacy `replaceWeeks`.
|
|
|
+- **Phase 22 — Per-task hamburger menu: move / copy / edit / reorder.**
|
|
|
+ Per-row admin button gathers Edit details / Move to sprint / Copy to
|
|
|
+ sprint / Move (pick up) / Delete. Tasks gain `description` (≤ 8000
|
|
|
+ chars), `url` (`http(s)://`, ≤ 2048 chars), and `linked_task_id` for
|
|
|
+ bidirectional cross-sprint copy chips. Two new admin JSON endpoints
|
|
|
+ (`POST /tasks/{id}/move`, `POST /tasks/{id}/copy`). Migration 004.
|
|
|
+- **Sprint settings: secured Delete sprint action.** Danger-zone
|
|
|
+ `POST /sprints/{id}/delete` form gated by `requireAdmin` + CSRF +
|
|
|
+ typed-confirmation match; full-cascade audit (every descendant
|
|
|
+ snapshotted) plus an UPDATE audit for cross-sprint linked rows whose
|
|
|
+ `linked_task_id` was nulled out.
|
|
|
+
|
|
|
+### Changed
|
|
|
+
|
|
|
+- **Header cleanup: Import moved into the admin dropdown.** The
|
|
|
+ `/sprints/import` link moved from inline in the header to the top of
|
|
|
+ the admin section in the hamburger menu.
|
|
|
+- **New sprint form: drop weeks input.** `/sprints/new` no longer
|
|
|
+ collects `n_weeks`; the count is derived from `start_date` / `end_date`
|
|
|
+ (capped at 26; above that redirects with `?error=dates_too_long`).
|
|
|
+- **Task table polish.** Sortable headers gain `whitespace-nowrap`;
|
|
|
+ per-row assignment cells gain `whitespace-nowrap`; `.assign-status-*`
|
|
|
+ tint moved off the `<td>` onto the inner field.
|
|
|
+- **Filter dropdown close polish.** Owners / Status / Columns dropdowns
|
|
|
+ no longer get cropped on a single-row task table; close on
|
|
|
+ `mouseleave` with a 250 ms grace timer.
|
|
|
+- **Sprint view tabs + smart Close on present.** `/sprints/{id}` is
|
|
|
+ split into "Arbeitstage and capacity" + "Capacity and tasks" tabs,
|
|
|
+ persisted in localStorage. The present view's Close button calls
|
|
|
+ `history.back()` when possible, falling back to `window.close()` then
|
|
|
+ to navigation.
|
|
|
+- **Cell popover replaces per-cell status select.** Single body-attached
|
|
|
+ `.cell-popover` panel anchored 8 px right of the cell; left column is
|
|
|
+ a slider whose max comes from `assignment_slider_max` (1..100,
|
|
|
+ default 10), right column is four status pills.
|
|
|
+
|
|
|
+### Removed
|
|
|
+
|
|
|
+- **Number-stepper slider popover (Phase 17).** Removed after seven
|
|
|
+ iterations failed to land reliable behaviour; the team prefers plain
|
|
|
+ typed entry. The `@layer base` rule that hides native number-spinner
|
|
|
+ arrows app-wide stays.
|
|
|
+
|
|
|
+### Security (R01-N* — `doc/REVIEW_01.md`)
|
|
|
+
|
|
|
+- **R01-N01 — Hash-only local-admin password.** `LOCAL_ADMIN_PASSWORD`
|
|
|
+ → `LOCAL_ADMIN_PASSWORD_HASH`; `password_verify()` against a bcrypt
|
|
|
+ hash; no plaintext fallback.
|
|
|
+- **R01-N02 / R01-N31 — Runtime panel on `/` is admin-only.**
|
|
|
+ `views/home.twig`'s "Runtime" `<details>` block was leaking PHP
|
|
|
+ version, env, SQLite path, schema version, and OIDC flags to anonymous
|
|
|
+ visitors; tightened to `currentUser is not null and currentUser.isAdmin`.
|
|
|
+ In-page `/healthz` hint also removed (the route stays public for
|
|
|
+ liveness probes).
|
|
|
+- **R01-N03 — Explicit env-bootstrap for the first OIDC admin.** OIDC
|
|
|
+ no longer auto-promotes the first user to land on `/auth/callback`;
|
|
|
+ `BOOTSTRAP_ADMIN_OID` / `BOOTSTRAP_ADMIN_EMAIL` name the principal up
|
|
|
+ front. Without one of them set, OIDC never auto-promotes anyone.
|
|
|
+- **R01-N04 — `SESSION_SECRET` removed.** The env var was documented but
|
|
|
+ unused; deployments that rotated it got a false sense of security.
|
|
|
+- **R01-N05 + R01-N07 — Trusted-proxy aware HTTPS detection & client
|
|
|
+ IP.** `TrustedProxies` honours a comma-separated `TRUSTED_PROXIES=`
|
|
|
+ env var and walks the XFF chain right-to-left; with the env blank,
|
|
|
+ forwarded headers are ignored. Session cookie marked `Secure` when
|
|
|
+ either `APP_BASE_URL` is HTTPS or the live request is effectively
|
|
|
+ HTTPS via a trusted proxy. One-shot HTTP→HTTPS redirect (308) when
|
|
|
+ the proxy explicitly reports `X-Forwarded-Proto: http`.
|
|
|
+- **R01-N06 — Throttle local-admin login by (ip, email).** Migration
|
|
|
+ 005 adds `auth_throttle`. Policy in `computeLockout(attempts, now)`:
|
|
|
+ 1–4 → no lock; 5–9 → +5 min; 10–19 → +30 min; 20+ → +1 hour. Counter
|
|
|
+ rolls over after a 15-min idle window; success deletes the row.
|
|
|
+ `LOGIN_FAILED` audit row distinguishes throttled hits from
|
|
|
+ credential mismatches.
|
|
|
+- **R01-N08 — Idle session timeout + CSRF rotation on login.** 30-min
|
|
|
+ idle window (`IDLE_TIMEOUT_SECONDS = 1800`); `last_active` stamp
|
|
|
+ drops auth keys + regenerates the session id past the threshold.
|
|
|
+ Login now `unset()`s `csrf_token` so a pre-login token can't be
|
|
|
+ replayed against the authenticated session.
|
|
|
+- **R01-N10 — Bind `sprint_id` with placeholders in `MAX(sort_order)`
|
|
|
+ lookups.** Three repo-level read paths previously interpolated the
|
|
|
+ integer parameter directly into SQL (not exploitable today; route
|
|
|
+ layer int-casts) — switched to prepared statements.
|
|
|
+- **R01-N11 — Whitelist column in
|
|
|
+ `AuditRepository::distinctColumn`.** Private helper interpolated its
|
|
|
+ `$col` argument; added explicit `in_array($col, ['action',
|
|
|
+ 'entity_type'], true)` guard.
|
|
|
+- **R01-N12 — Validate `/audit` date filters server-side.** Strict
|
|
|
+ `Y-m-d` parse via `DateTimeImmutable::createFromFormat` round-trip
|
|
|
+ equality; bad input drops the filter and surfaces a per-field error.
|
|
|
+- **R01-N13 — Fatal-error safety net.** New `FatalErrorHandler`
|
|
|
+ registers both `set_exception_handler` and a fatal-mask
|
|
|
+ `register_shutdown_function`. On fire, every output buffer is drained
|
|
|
+ and a minimal 500 page is written with the same security headers a
|
|
|
+ normal response carries (CSP / HSTS / X-Frame-Options /
|
|
|
+ X-Content-Type-Options / Referrer-Policy). `public/index.php` now
|
|
|
+ sources its security headers from
|
|
|
+ `FatalErrorHandler::securityHeaders()` — single source of truth.
|
|
|
+- **R01-N14 — XLSX session payload cap.** The full parsed workbook
|
|
|
+ was JSON-stashed in the session file with no upper bound on parsed
|
|
|
+ expansion; capped at 2 MiB via `MAX_SESSION_PAYLOAD_BYTES`. Pruning
|
|
|
+ paths emit an `IMPORT_PREVIEW_ABANDONED` audit row.
|
|
|
+- **R01-N15 — `noreferrer` on external task URL link.** The
|
|
|
+ user-controlled `t.url` link in `views/sprints/_task_list.twig` now
|
|
|
+ carries `rel="noopener noreferrer"`; sequential sprint IDs no longer
|
|
|
+ leak via Referer.
|
|
|
+- **R01-N16 — `bin/audit.sh` composer-audit helper.** Wraps `composer
|
|
|
+ audit --locked --no-interaction` inside the runtime image so the
|
|
|
+ audit reflects the live container's dependency tree (host PHP often
|
|
|
+ lacks the right ext-* set). Honours `SPRINT_PLANER_IMAGE` for
|
|
|
+ non-default tags. Admin manual §5.5 documents a recommended cadence.
|
|
|
+- **R01-N18 — OIDC email trust path.** `OidcClaims::resolveEmail()` is
|
|
|
+ the single decision point: drops the `preferred_username` fallback;
|
|
|
+ honours `claims.email` only when `email_verified !== false`; falls
|
|
|
+ back to `entra:<oid>` when no trusted email is available.
|
|
|
+- **R01-N19 — CSP `report-uri /csp-report` + audit endpoint.** Strict
|
|
|
+ CSP gains a report URI; new public `POST /csp-report` route
|
|
|
+ (`CspReportController`) writes one audit row per browser-fired
|
|
|
+ report. Bodies that fail to decode or exceed the 16 KiB cap return
|
|
|
+ 204 with no row written.
|
|
|
+- **R01-N20 — `Response::redirect` rejects non-path locations.** Refuses
|
|
|
+ anything that is not a single `/` followed by a non-`/`, plus
|
|
|
+ CR/LF/NUL guard (header injection). The HTTP→HTTPS canonical
|
|
|
+ redirect moves to a new `Response::external($url, $status)` helper
|
|
|
+ that scheme-restricts to `http`/`https`.
|
|
|
+- **R01-N21 — Twig auto-escape pinned by tests.** Two regression tests
|
|
|
+ in `tests/Http/TwigAutoescapeTest.php`: a behaviour pin renders a
|
|
|
+ known XSS payload through the wired-up `View` env and asserts HTML
|
|
|
+ escaping; a static guard walks every `.twig` file under `views/` and
|
|
|
+ fails on any `|raw`, `|safe`, or `{% autoescape` directive.
|
|
|
+- **R01-N22 — Migrations move to deploy time.** `bin/docker-entrypoint.sh`
|
|
|
+ → `php bin/migrate.php` runs BEFORE Apache binds the port; a failed
|
|
|
+ migration aborts container start. The web request path only checks
|
|
|
+ `schema_version` and returns 503 when something is pending.
|
|
|
+- **R01-N23 — `users.tombstoned_at` for soft erasure.** Privacy-request
|
|
|
+ path that overwrites email / OID with placeholder values, retains
|
|
|
+ the row for FK integrity, and stops the user from signing back in.
|
|
|
+- **R01-N24 — 1 MiB body cap + 5000-item batch cap on JSON endpoints.**
|
|
|
+ Defends against memory-exhaustion via oversized POST bodies.
|
|
|
+- **R01-N25 — `X-Permitted-Cross-Domain-Policies: none` header.** Emit
|
|
|
+ alongside the other security headers.
|
|
|
+- **R01-N26 — One-shot session flash for post-delete chip.** Replaces
|
|
|
+ the query-string-driven flash so the message survives a cookie path
|
|
|
+ but doesn't bookmark.
|
|
|
+- **R01-N27 — Backgrounded session-file GC loop in entrypoint.** Long
|
|
|
+ running cleanup decoupled from the request path.
|
|
|
+- **R01-N28 — Drop dead `$changed[]` in `SettingsController::update`.**
|
|
|
+ Code-quality cleanup; no behaviour change.
|
|
|
+
|
|
|
+### Notes on accepted-by-design findings
|
|
|
+
|
|
|
+The following review findings were closed without code change after
|
|
|
+analysis; see `doc/REVIEW_01.md` for the rationale on each:
|
|
|
+R01-N09 (`SameSite=Lax` retained — `Strict` would block the OIDC
|
|
|
+callback), R01-N17 (concurrent-tab OIDC clobber is correct
|
|
|
+RFC behaviour), R01-N29, R01-N30, R01-N32, R01-N33, R01-N34.
|
|
|
+
|
|
|
+[Unreleased]: https://github.com/chiappa/sprint_planer_web/compare/v0.22.0...HEAD
|
|
|
+[0.22.0]: https://github.com/chiappa/sprint_planer_web/releases/tag/v0.22.0
|