# Story 2.1: Admin Configuration Dashboard

## Story

**As a** system administrator,
**I want** to configure Akeneo API credentials and the confidence threshold from a settings page,
**So that** I can control how the system connects to Akeneo and routes categorization decisions without touching server files.

## Status

done

## Acceptance Criteria

**AC1:** Given an authenticated admin navigates to `GET /admin/settings`, when the page loads, then a settings form is displayed with fields for: Akeneo Base URL, Akeneo Client ID, Akeneo Client Secret, Akeneo Username, Akeneo Password, and Confidence Threshold (%). Current saved values are pre-populated in the form. Secret fields (`akeneo_client_secret`, `akeneo_password`) are rendered as `<input type="password">` with an empty `value` attribute and a placeholder of `••••••••` when a value is already stored — the stored secret is never echoed to the HTML. The Confidence Threshold field shows its current value (default: `90`) and accepts integer input between 1 and 100. A "Test Akeneo Connection" button is visible alongside the Akeneo credential fields.

**AC2:** Given an admin submits the settings form with valid values, when the form is POST'd to `/admin/settings`, then all values are saved to the `system_settings` table (one row per key). Credentials are stored with keys: `akeneo_base_url`, `akeneo_client_id`, `akeneo_client_secret`, `akeneo_username`, `akeneo_password`. The confidence threshold is stored with key `confidence_threshold`. **Secret fields submitted empty preserve the existing stored value** (no overwrite to empty string). A success message is displayed: "Settings saved." The CSRF token is validated on submission; a mismatch returns a 403 with a human-readable error.

**AC3:** Given an admin submits the form with an invalid confidence threshold (e.g., `0`, `101`, or non-numeric), when `AdminController` validates the input, then the form is re-rendered with a field-specific error: "Confidence threshold must be a whole number between 1 and 100." No settings are saved (the entire save is aborted on validation failure, not just the threshold field).

**AC4:** Given the `system_settings` table migration is run via Phinx, when the schema is inspected, then the `system_settings` table exists with columns: `id` (serial PK), `key` (varchar(100), unique, not null), `value` (text, nullable), `updated_at` (timestamp, default `CURRENT_TIMESTAMP`), `updated_by` (integer, FK to `users.id`, nullable, `ON DELETE SET NULL`). A unique index exists on `key` (`idx_system_settings_key`).

**AC5:** Given an admin clicks "Test Akeneo Connection", when the AJAX request is sent to `POST /api/admin/test-akeneo-connection`, then `AkeneoService::testConnection()` attempts to authenticate against `{base_url}/api/oauth/v1/token` using the currently saved credentials (NOT the form values — the user must Save first). A success result returns JSON `{"success": true, "message": "Connection successful."}` and the UI displays it with a green status indicator. A failure result returns JSON `{"success": false, "message": "Connection failed: <reason>"}` (e.g., "Invalid credentials", "Host unreachable", "Settings incomplete") with a red status indicator. The raw exception/HTTP body is logged via `Logger::error()` but is NOT exposed in the JSON response.

**AC6:** Given an unauthenticated user attempts `GET /admin/settings`, `POST /admin/settings`, or `POST /api/admin/test-akeneo-connection`, when the auth check runs, then they are redirected to `/login?redirect=...` (HTML routes) or returned a 401 JSON response (the AJAX route). Given an authenticated user without `role = 'admin'` attempts those routes, then they are redirected to `/?error=forbidden` (HTML) or returned 403 JSON.

**AC7:** Given the CSS classes for the status and confidence badge systems exist in `public/assets/css/styles.css`, when the file is inspected, then the following exact color values are present:
- `.badge-status-predicted` → `color: #0969da` (blue)
- `.badge-status-needs-review` → `color: #d29922` (amber)
- `.badge-status-submitted` → `color: #0969da` (blue)
- `.badge-status-awaiting-akeneo` → `color: #d29922` (amber)
- `.badge-status-approved` → `color: #3fb950` (green)
- `.badge-status-rejected` → `color: #f85149` (red)
- `.badge-status-auto-approved` → `color: #7d8590` (muted gray)
- `.badge-status-import-failed` → `color: #f85149` (red)
- `.badge-confidence-high` → `color: #3fb950` (green)
- `.badge-confidence-medium` → `color: #d29922` (amber)
- `.badge-confidence-low` → `color: #f85149` (red)

**AC8:** Given the sidebar nav `Admin` link in `src/Views/layout.php`, when an admin is logged in, then the link points to `/admin/settings` (not `#`). The active state highlights when `$activePage === 'admin'`.

## Tasks / Subtasks

- [x] **Task 1: Create `system_settings` migration** (AC: 4)
  - [x] 1.1: Create `db/migrations/20260521000001_create_system_settings_table.php` with the columns, types, and unique index defined in AC4. Use Phinx `addForeignKey('updated_by', 'users', 'id', ['delete' => 'SET_NULL', 'update' => 'NO_ACTION'])`.
  - [x] 1.2: Run `vendor/bin/phinx migrate` and verify the schema (`\d system_settings` in psql).

- [x] **Task 2: Create `SystemSettingsService`** (AC: 2, 5)
  - [x] 2.1: Create `src/Services/SystemSettingsService.php` with methods: `get(string $key, ?string $default = null): ?string`, `getAll(array $keys): array` (returns `['key' => 'value']`), `set(string $key, ?string $value, ?int $updatedBy = null): void` (uses PostgreSQL `INSERT ... ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW(), updated_by = EXCLUDED.updated_by`).
  - [x] 2.2: All methods use `Database::getConnection()` and prepared statements.

- [x] **Task 3: Create `AkeneoService`** (AC: 5)
  - [x] 3.1: Create `src/Services/AkeneoService.php` with constructor accepting `SystemSettingsService` (manual dependency injection — no DI container).
  - [x] 3.2: Implement `testConnection(): array` returning `['success' => bool, 'message' => string]`. Reads the five `akeneo_*` keys via `SystemSettingsService::getAll()`. If any required key is empty, return `['success' => false, 'message' => 'Connection failed: Settings incomplete']`. Otherwise, POST to `{base_url}/api/oauth/v1/token` with HTTP Basic auth header `Authorization: Basic base64(client_id:client_secret)` and form body `grant_type=password&username={username}&password={password}`. Use native cURL with `CURLOPT_CONNECTTIMEOUT=5`, `CURLOPT_TIMEOUT=10`, `CURLOPT_SSL_VERIFYPEER=true`, `CURLOPT_RETURNTRANSFER=true`.
  - [x] 3.3: Map outcomes to user-safe messages:
    - HTTP 200 with `access_token` in JSON → `['success' => true, 'message' => 'Connection successful.']`
    - HTTP 401/400 → `'Connection failed: Invalid credentials'`
    - cURL error `CURLE_COULDNT_RESOLVE_HOST` or `CURLE_COULDNT_CONNECT` → `'Connection failed: Host unreachable'`
    - cURL timeout → `'Connection failed: Request timed out'`
    - Any other failure → `'Connection failed: Unexpected error'`
  - [x] 3.4: On any failure path, call `Logger::error('Akeneo test connection failed', ['http_code' => ..., 'curl_errno' => ..., 'curl_error' => ...])`. **Never** log the password, client_secret, or response body.

- [x] **Task 4: Create `AdminController`** (AC: 1, 2, 3, 5, 6)
  - [x] 4.1: Create `src/Controllers/AdminController.php` with constructor wiring `SystemSettingsService` and `AkeneoService` (manual instantiation).
  - [x] 4.2: `showSettings(): void` — loads the six setting keys via `SystemSettingsService::getAll()`, computes `$hasSecret = ['client_secret' => !empty(...), 'password' => !empty(...)]` for the placeholder display logic, sets `$activePage = 'admin'`, requires the view.
  - [x] 4.3: `saveSettings(): void` — validates CSRF (returns 403 + render with error on mismatch); validates confidence threshold with `filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 100]])`; if invalid, re-renders the form with `$errors['confidence_threshold']` and the submitted (non-secret) values preserved; trims all string inputs; for `akeneo_client_secret` and `akeneo_password`, if the POST value is an empty string, do NOT call `set()` for that key (preserve existing); for all other keys, always `set()`; on success, set `$_SESSION['flash_success'] = 'Settings saved.'` and redirect to `/admin/settings` (POST→Redirect→GET pattern).
  - [x] 4.4: `testConnection(): void` — validates CSRF; if invalid, returns `header('Content-Type: application/json'); http_response_code(403); echo json_encode([...])`. Calls `AkeneoService::testConnection()` and echoes the result as JSON with `Content-Type: application/json`. HTTP 200 in all cases (success/failure is in the JSON body).

- [x] **Task 5: Create admin settings view** (AC: 1, 2, 3, 7)
  - [x] 5.1: Create directory `src/Views/admin/` (new directory).
  - [x] 5.2: Create `src/Views/admin/settings.php` — uses the main layout (sidebar visible). Render form with two-column layout (per UX spec "Admin settings: 2-column form layout"). Fields ordered: Akeneo Base URL, Client ID, Client Secret, Username, Password, Confidence Threshold. Each input includes `name`, `id`, `value` (empty for secret fields), `placeholder` (`••••••••` for secret fields if stored, empty otherwise), `class="form-control"`, `required` attribute on Base URL/Client ID/Username/Confidence Threshold only (secrets not required if already stored — backend handles this).
  - [x] 5.3: Include hidden `_csrf_token` field. Include success banner (`$_SESSION['flash_success']`) at the top if set, then `unset($_SESSION['flash_success'])`. Include error banner above field if `$errors` non-empty.
  - [x] 5.4: Below the Akeneo Password field, add a "Test Akeneo Connection" `<button type="button" id="test-akeneo-connection">` and a `<div id="akeneo-test-result"></div>` for the AJAX result indicator.
  - [x] 5.5: Include `<script src="/assets/js/admin.js"></script>` at the bottom.

- [x] **Task 6: Create admin.js for AJAX test** (AC: 5)
  - [x] 6.1: Create `public/assets/js/admin.js` — plain JS, no frameworks. Attach click listener to `#test-akeneo-connection`. Read the CSRF token from the form's hidden `_csrf_token` field. POST to `/api/admin/test-akeneo-connection` with `Content-Type: application/x-www-form-urlencoded` and body `_csrf_token=<token>`. Show spinner while pending; on response, populate `#akeneo-test-result` with one of: `<div class="error-banner error-banner-success">Connection successful.</div>` or `<div class="error-banner error-banner-error"><message></div>`. Use `fetch()` with `try/catch`; on network error, show `Connection failed: Network error`.

- [x] **Task 7: Wire routes and update layout** (AC: 1, 2, 5, 6, 8)
  - [x] 7.1: Update `public/index.php` — instantiate `SystemSettingsService`, `AkeneoService`, `AdminController`. Add: `GET /admin/settings`, `POST /admin/settings`, `POST /api/admin/test-akeneo-connection`. Each handler must call `AuthMiddleware::requireRole('admin')` as the first line.
  - [x] 7.2: Adjust the AJAX route's auth failure to return JSON instead of redirecting — see "Critical: AJAX vs HTML auth" in Dev Notes.
  - [x] 7.3: Update `src/Views/layout.php` — change the Admin nav `<a href="#">` to `<a href="/admin/settings">`.

- [x] **Task 8: Update badge CSS color values** (AC: 7)
  - [x] 8.1: In `public/assets/css/styles.css`, change `.badge-status-awaiting-akeneo` color from `#b45309` to `#d29922`.
  - [x] 8.2: Change `.badge-status-approved` color from `#1a7f37` to `#3fb950`.
  - [x] 8.3: Change `.badge-status-rejected` color from `#cf222e` to `#f85149`.
  - [x] 8.4: Change `.badge-status-import-failed` color from `#cf222e` to `#f85149`.
  - [x] 8.5: Confidence badge colors (`-high`, `-medium`, `-low`) already match AC7 — verify no change needed.

- [x] **Task 9: Tests** (AC: 2, 3, 5)
  - [x] 9.1: Create `tests/Unit/SystemSettingsServiceTest.php` — test `set()` then `get()` round-trip, `set()` with same key updates (upsert), `getAll()` returns only requested keys, `get()` returns default when key missing. Clean up test keys in `tearDown()`.
  - [x] 9.2: Create `tests/Unit/AdminControllerValidationTest.php` — test the threshold validation logic directly (extract into a static method `AdminController::validateConfidenceThreshold(mixed $value): ?int` if needed to make it testable; returns int on success, null on failure). Cover: `1`, `100`, `0`, `101`, `'90'` (string), `'abc'`, `null`, `''`.
  - [x] 9.3: Run full test suite. Expected: 22 prior tests + new tests, all passing.

- [x] **Task 10: Manual validation against all ACs**
  - [x] 10.1: Log in as admin; navigate to `/admin/settings`; verify form renders with empty fields (fresh install) or saved values pre-populated, secrets shown as placeholder dots.
  - [x] 10.2: Save settings with bogus Akeneo URL; click Test; verify red banner shows "Host unreachable" or similar.
  - [x] 10.3: Submit threshold = `150`; verify field-level error appears, no settings saved.
  - [x] 10.4: Submit valid form with secret fields empty; verify previously-stored secrets remain in DB.
  - [x] 10.5: As a non-admin user (manually update a test user's role to `user`), attempt `/admin/settings`; verify redirect to `/?error=forbidden`.

## Dev Notes

### Architecture & Conventions (from architecture.md)

- **Naming:** DB tables snake_case plural (`system_settings`); index `idx_<table>_<col>`; PHP classes PascalCase; methods camelCase. [Source: architecture.md#Naming Patterns]
- **API endpoints:** kebab-case under `/api/` prefix → `/api/admin/test-akeneo-connection`. [Source: architecture.md#API Naming Conventions]
- **JSON response envelope:** Use `{"success": bool, "message": "..."}` for this AJAX endpoint. (Architecture's full `{"success": true, "data": {...}}` envelope is for resource endpoints; this is a status-only check.) [Source: architecture.md#API Response Formats — adapted]
- **Thin controllers:** Controllers handle HTTP only; all business logic lives in Services. `AdminController` orchestrates `SystemSettingsService` and `AkeneoService`. [Source: architecture.md#Code Organization]
- **PDO:** Always prepared statements. `Database::getConnection()` already sets `PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC` and `PDO::ATTR_EMULATE_PREPARES => false`. [Source: `src/Utils/Database.php:29-33`]
- **Logging:** Use `Logger::error()` for failures, `Logger::info()` for audit events (e.g., "Settings updated by admin X"). JSON-line format. [Source: `src/Utils/Logger.php`]

### Critical: AJAX vs HTML auth handling

`AuthMiddleware::requireAuth()` and `requireRole()` currently `header('Location: ...'); exit;` on failure. This is wrong for the AJAX endpoint — a JS `fetch()` call will follow the redirect and try to parse `/login` HTML as JSON.

**Approach for this story (minimal-change, no AuthMiddleware refactor):** in the route handler for `/api/admin/test-akeneo-connection`, do the auth check inline instead of calling `requireRole()`:

```php
$router->post('/api/admin/test-akeneo-connection', function () use ($admin) {
    header('Content-Type: application/json');
    if (empty($_SESSION['user_id'])) {
        http_response_code(401);
        echo json_encode(['success' => false, 'message' => 'Not authenticated.']);
        return;
    }
    if (($_SESSION['user_role'] ?? '') !== 'admin') {
        http_response_code(403);
        echo json_encode(['success' => false, 'message' => 'Forbidden.']);
        return;
    }
    $admin->testConnection();
});
```

Do NOT modify `AuthMiddleware` for this story — a future story can introduce an `expectsJson()` mechanism if more AJAX routes emerge. Document this as a deferred concern in the completion notes.

### Secret handling — non-negotiable

- Stored secrets must NEVER appear in HTML output. The form renders secret inputs with empty `value=""` and a placeholder of `••••••••` only when a value exists in `system_settings`.
- An empty submitted secret field means "leave the stored value alone," NOT "clear it." Implement this in `AdminController::saveSettings()`, NOT in `SystemSettingsService::set()` — the service is a dumb data layer.
- Logger calls must never receive the secret/password as context values. Stick to metadata: HTTP code, cURL errno.

### Existing files being modified (read these first)

- **`public/index.php`** — currently has `/login`, `/logout`, `/` routes only. Will gain 3 new routes. Preserve the existing CLI-server static-file passthrough at the top. The `Router` only matches exact paths (no params), which is fine for this story.
- **`src/Views/layout.php`** — sidebar has 4 nav items; only the `Admin` item link needs updating from `#` to `/admin/settings`. Do not touch the other nav items.
- **`public/assets/css/styles.css`** — color values only; do not restructure or remove unrelated rules.

### `config/akeneo.php` — leave alone

The current `config/akeneo.php` reads Akeneo credentials from `.env`. After this story, the **runtime source of truth is `system_settings`**. Do not delete `config/akeneo.php` in this story — a follow-up story will remove or repurpose it. `AkeneoService` reads exclusively from `SystemSettingsService`. Note this in the deferred-work file.

### Previous Story Intelligence (1.2 — User Authentication)

- **Session pattern:** `$_SESSION['user_id']`, `$_SESSION['user_name']`, `$_SESSION['user_role']` are populated on login. Use `$_SESSION['user_id']` as the `updated_by` value when calling `SystemSettingsService::set()`.
- **CSRF pattern:** `CsrfMiddleware::validate()` reads `$_POST['_csrf_token']`. For AJAX, the JS must pass the token in the form body (NOT as a header), matching this pattern. Token lifetime is per-session.
- **Admin role:** The seeded default admin user has `role = 'admin'`. `AuthMiddleware::requireRole('admin')` exists and redirects to `/?error=forbidden` on mismatch.
- **Flash messages:** No flash-message convention exists yet. Introduce `$_SESSION['flash_success']` / `$_SESSION['flash_error']` for the POST→Redirect→GET pattern. Unset after rendering. Keep it minimal — no Flash helper class needed.
- **From 1.2 review (just completed):** Open-redirect regex was tightened. The new redirect after save is hardcoded to `/admin/settings`, so no validation needed there.
- **Carry-forward deferred items (1.2):** W2 (`secure: false` cookie), W12 (`getCurrentUser` no DB re-check), W14 (`declare(strict_types=1)` missing) — apply consistency, do NOT retroactively fix in this story.

### File List (anticipated)

**New files:**
- `db/migrations/20260521000001_create_system_settings_table.php`
- `src/Services/SystemSettingsService.php`
- `src/Services/AkeneoService.php`
- `src/Controllers/AdminController.php`
- `src/Views/admin/settings.php`
- `public/assets/js/admin.js`
- `tests/Unit/SystemSettingsServiceTest.php`
- `tests/Unit/AdminControllerValidationTest.php`

**Modified files:**
- `public/index.php` (3 new routes, service wiring)
- `src/Views/layout.php` (Admin nav link)
- `public/assets/css/styles.css` (4 color value updates)

### Testing Standards

- PHPUnit at `vendor/bin/phpunit`. Existing unit tests under `tests/Unit/`. Tests that touch the DB use `App\Utils\Database::getConnection()` against a real Postgres (per project convention — see deferred item W8). Use unique test prefixes (e.g., `_test_setting_xxx`) and clean up in `tearDown()`.
- For `AdminControllerValidationTest`, prefer testing the validation as a pure static helper to avoid HTTP/session setup overhead. If the helper is private, lift it to a `public static` method.

### Out of scope for this story

- Audit log entry for each settings change (covered by Logger, but no dedicated `settings_audit` table).
- Admin user management UI (different story).
- Per-user role assignment UI (different story).
- Settings versioning/rollback.
- Migrating `.env` Akeneo credentials into `system_settings` (assume fresh install).
- AuthMiddleware refactor for JSON responses (handle inline; see "Critical: AJAX vs HTML auth").

## Dev Agent Record

### Agent Model Used

claude-sonnet-4-6

### Debug Log References

### Completion Notes List

All 8 acceptance criteria satisfied and verified via live HTTP smoke testing against the built-in PHP server.

- **AC1:** `GET /admin/settings` returns 200 with all six fields. Secret fields render `value=""` with `placeholder="••••••••"` when a stored value exists; otherwise empty placeholder. Confidence threshold defaults to `90` on a fresh install and accepts `min=1 max=100 step=1`.
- **AC2:** `POST /admin/settings` upserts each key via PostgreSQL `INSERT ... ON CONFLICT`. **Empty secret submissions preserve stored values** — verified live: client_secret/password retained their original DB values while non-secret fields updated. PRG redirect to `/admin/settings`; flash "Settings saved." rendered on the next GET. CSRF mismatch → 403 with re-rendered form.
- **AC3:** Threshold `150` (and other invalid values) re-renders the form with the exact error string "Confidence threshold must be a whole number between 1 and 100." Validation aborts the entire save (no partial writes).
- **AC4:** Schema verified via `\d system_settings`: serial PK, `key` varchar(100) UNIQUE NOT NULL, `value` text NULL, `updated_at` timestamp DEFAULT `CURRENT_TIMESTAMP`, `updated_by` integer NULL with `ON DELETE SET NULL` FK to `users.id`. Unique index `idx_system_settings_key`.
- **AC5:** `testConnection()` uses OAuth2 password-grant against `{base_url}/api/oauth/v1/token` with Basic auth header. Live test against `https://nonexistent.example.invalid` returned `{"success":false,"message":"Connection failed: Host unreachable"}` — no raw cURL error leaked. CSRF mismatch → 403 JSON `{"success":false,"message":"Invalid form submission. Please try again."}`. Errors logged via `Logger::error()` with only `http_code` / `curl_errno` (never secrets).
- **AC6:** HTML routes redirect unauthenticated → `/login?redirect=%2Fadmin%2Fsettings`. AJAX route returns 401 JSON `{"success":false,"message":"Not authenticated."}` (auth check inline, NOT via `AuthMiddleware::requireRole` — that path is HTML-only and would redirect). Verified live.
- **AC7:** All 11 badge color values now match AC exactly. `awaiting-akeneo` `#b45309 → #d29922`, `approved` `#1a7f37 → #3fb950`, `rejected` `#cf222e → #f85149`, `import-failed` `#cf222e → #f85149`. Confidence badge colors already correct.
- **AC8:** `src/Views/layout.php` Admin nav `href` changed from `#` to `/admin/settings`. Active state highlights when `$activePage === 'admin'` (set by `AdminController::showSettings()`).

Tests: 38 total, 66 assertions, all passing (22 prior + 16 new — `SystemSettingsServiceTest` 7 tests, `AdminControllerValidationTest` 9 tests).

Deferred: `config/akeneo.php` left in place (env-based) — to be cleaned up in a follow-up story; AkeneoService reads exclusively from `SystemSettingsService`. Inline JSON auth in `public/index.php` route — to be replaced by an `AuthMiddleware::requireJsonRole()` helper when more AJAX routes appear.

### File List

```
db/migrations/20260521000001_create_system_settings_table.php
src/Services/SystemSettingsService.php
src/Services/AkeneoService.php
src/Controllers/AdminController.php
src/Views/admin/settings.php
public/assets/js/admin.js
tests/Unit/SystemSettingsServiceTest.php
tests/Unit/AdminControllerValidationTest.php
public/index.php                       (modified — admin routes + service wiring)
src/Views/layout.php                   (modified — Admin nav link)
public/assets/css/styles.css           (modified — 4 badge color value corrections)
```

### Review Findings

- [x] [Review][Patch] SSRF: `akeneo_base_url` accepts any scheme/host; add `https://` enforcement, `CURLOPT_PROTOCOLS` restriction, URL format check [src/Services/AkeneoService.php] — fixed via `isSafeAkeneoUrl()` (rejects non-HTTPS, whitespace, CR/LF) + `CURLOPT_PROTOCOLS => CURLPROTO_HTTPS` + `CURLOPT_SSL_VERIFYHOST => 2`; verified live (`http://169.254.169.254` returns "Invalid Base URL")
- [x] [Review][Patch] `trim()` mangles secret values with leading/trailing whitespace; skip trim for SECRET_KEYS [src/Controllers/AdminController.php:65] — fixed; secrets stored verbatim, non-secrets still trimmed; verified live
- [x] [Review][Patch] Settings save loops `set()` without a transaction — partial-update on mid-loop failure [src/Controllers/AdminController.php] — fixed via `beginTransaction()` / `commit()` / `rollBack()` on `\Throwable`
- [x] [Review][Patch] No `CURLOPT_MAXFILESIZE` cap on Akeneo test response [src/Services/AkeneoService.php] — fixed; 1 MB cap added
- [x] [Review][Patch] `$errStr = curl_error($ch)` captured but never used [src/Services/AkeneoService.php] — fixed; now included in `Logger::error` context for admin debugability (never returned to client)
- [x] [Review][Patch] Admin pages render credential identifiers without `Cache-Control: no-store` [src/Controllers/AdminController.php] — fixed via `Cache-Control: no-store, no-cache, must-revalidate, private` + `Pragma: no-cache` in `renderSettings()`
- [x] [Review][Defer] Plaintext secret storage — deferred, architecture decision (self-hosted trusted env, no DB-at-rest encryption per architecture.md#Data Encryption)
- [x] [Review][Defer] No rate limiting on `/api/admin/test-akeneo-connection` — deferred, consistent with login (no rate limiting) and out of Story 2.1 scope
- [x] [Review][Defer] Inline JSON auth in `public/index.php` route — deferred, story dev notes explicitly document carry-forward to a future `AuthMiddleware::requireJsonRole()` helper
- [x] [Review][Defer] CSRF token rotation after successful save — deferred, best practice not in story scope
- [x] [Review][Defer] `system_settings` has no `created_at` column — deferred, AC4 schema fully specified without it
- [x] [Review][Defer] `SystemSettingsService::set()` accepts any key (no whitelist) — deferred, controller is sole gate; revisit if other writers appear
- [x] [Review][Defer] No `AbortController` on JS fetch + no server-side test lock — deferred, low concurrency for internal admin tool
- [x] [Review][Defer] `CURLOPT_TIMEOUT=10` may be tight on slow networks — deferred, raise if real-world feedback warrants

## Change Log

| Date | Change |
|------|--------|
| 2026-05-20 | Story created from epics.md; status set to ready-for-dev |
| 2026-05-20 | All 10 tasks complete; 38/38 tests passing; live HTTP smoke test verified all 8 ACs; status set to review |
| 2026-05-20 | Code review complete; 6 patch findings, 8 deferred; status set to in-progress |
| 2026-05-20 | All 6 patches applied (SSRF guard, no-trim on secrets, save transaction, curl MAXFILESIZE, curl_error logging, Cache-Control no-store); 38/38 tests still passing; status set to done |
