From 7e675e7beb25a8ddcb3f6cc6f0101f0ffd668e1e Mon Sep 17 00:00:00 2001 From: central-worker-1 Date: Wed, 4 Nov 2026 06:55:35 -0700 Subject: [PATCH] #6168: Help Desk Settings can reach its email connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email-connection feature was whole on the server and unreachable in the product. `ticketing_email_connections` has shipped since 2026_04_08_000008, the three write routes are live and healthy — an empty POST answers a 422 validation bag — and index() has been computing an `emailConnections` prop on EVERY settings render. The one thing missing was the segment that lets somebody look at it: `email` was not in the `{tab}` whitelist, so the only deep link anybody could guess fell through to the POST-only `/settings/email` and answered 405 while its five siblings answered 200. The segment is in the whitelist now, and the shell's Settings.vue grows the tab that renders the prop (dashboard-shell, same branch). Two things about the prop had to change before a UI could read it: · the stored mailbox PASSWORD no longer leaves the server. It was sitting in `config` on a prop that ships to everyone who can open Help Desk Settings; nothing rendered it before, so nothing is lost by shaping the rows by hand and sending `has_password` instead of the credential. · a save with the password box blank KEEPS the stored password. The form cannot prefill a password it is never sent, so an empty box has to mean "unchanged" — otherwise correcting a host would silently disconnect the mailbox, and the row would still read as configured. Proof: tests/Feature/Ticketing/EmailConnectionsHaveADoorTest.php (shell) — 17 tests, including every tab key read out of the Vue's own TAB_KEYS and asserted 200. Co-Authored-By: Claude Opus 5 --- .../TicketingSettingsController.php | 62 ++++++++++++++++++- src/routes/ticketing.php | 9 ++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php index a8c8321..f5482c1 100644 --- a/src/Http/Controllers/TicketingSettingsController.php +++ b/src/Http/Controllers/TicketingSettingsController.php @@ -331,11 +331,36 @@ class TicketingSettingsController extends Controller ->orderBy('name') ->get(); - $emailConnections = $isBootstrap + // #6168 — the Email tab's rows. Shaped by hand rather than handed over + // as models for one reason: `config` holds the mailbox PASSWORD, and + // this prop ships on every settings load, so a credential left in it is + // a credential in the page source of everyone who can open Help Desk + // Settings. The form asks for a new password instead and + // updateEmailConnection() keeps the stored one when the box is blank, + // so nothing needs it on the client — `has_password` is all the UI has + // to know to say "a password is set". + $emailConnections = ($isBootstrap ? collect() : EmailConnection::when($visibleGroupIds !== null, fn($q) => $q->whereIn('group_id', $visibleGroupIds)) - ->get(); + ->orderBy('id') + ->get()) + ->map(function (EmailConnection $connection) { + $config = $connection->config ?? []; + $hasPassword = ($config['password'] ?? '') !== ''; + unset($config['password']); + + return [ + 'id' => $connection->id, + 'group_id' => $connection->group_id, + 'type' => $connection->type, + 'active' => (bool) $connection->active, + 'config' => $config, + 'has_password' => $hasPassword, + 'last_polled_at' => $connection->last_polled_at?->toIso8601String(), + ]; + }) + ->values(); return Inertia::render('Ticketing/Settings', [ 'activeTab' => $tab, @@ -910,12 +935,43 @@ class TicketingSettingsController extends Controller $connection->update([ 'type' => $request->type, 'active' => $request->boolean('active', true), - 'config' => $request->input('config', $connection->config), + 'config' => $this->mergedConnectionConfig($request, $connection), ]); return back()->with('success', 'Email connection updated.'); } + /** + * The config to store for an edited connection. + * + * #6168 — the page is never sent the stored password (see index()), so the + * edit form's password box arrives EMPTY on every save that does not change + * it. Taking the request's config as-is would therefore erase the mailbox + * credential every time somebody renamed a host or flipped the Active + * checkbox. A blank box means "keep the one you have"; typing a new one + * replaces it, and the rest of the config is the request's, so a field + * cleared on purpose still clears. + */ + private function mergedConnectionConfig(Request $request, EmailConnection $connection): array + { + $config = $request->input('config'); + $existing = $connection->config ?? []; + + if (! is_array($config)) { + return $existing; + } + + if (($config['password'] ?? '') === '') { + unset($config['password']); + + if (($existing['password'] ?? null) !== null) { + $config['password'] = $existing['password']; + } + } + + return $config; + } + public function destroyEmailConnection(EmailConnection $connection) { $this->requireAgentAccess(); diff --git a/src/routes/ticketing.php b/src/routes/ticketing.php index f19d798..514485a 100644 --- a/src/routes/ticketing.php +++ b/src/routes/ticketing.php @@ -41,8 +41,15 @@ Route::middleware(['web', 'auth', 'app.access:ticketing']) // ── Settings (must be before /{ticket} wildcard) ────────────────────── Route::middleware('permission:ticketing.settings')->group(function () { Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings'); + // #6168 — `email` belongs in this list. It was the one tab segment + // the whitelist omitted while the page shipped an `emailConnections` + // prop on every render and the three write routes below were live, so + // the only deep link an admin could guess fell through to the POST-only + // `/settings/email` and answered 405. The Vue's TAB_KEYS and this list + // are now pinned to each other by + // tests/Unit/TicketingSettingsTabsAreOneVocabularyTest.php. Route::get('/settings/{tab}', [TicketingSettingsController::class, 'index']) - ->where('tab', 'groups|agents|priorities|statuses|projects|automation')->name('settings.tab'); + ->where('tab', 'groups|agents|priorities|statuses|projects|email|automation')->name('settings.tab'); Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store'); Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update'); Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');