diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php index b4ae139..e2093d4 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, @@ -913,12 +938,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');