Merge commit '7e675e7beb25a8ddcb3f6cc6f0101f0ffd668e1e' into HEAD

This commit is contained in:
paired-gate
2026-11-05 04:46:14 -07:00
2 changed files with 67 additions and 4 deletions

View File

@@ -331,11 +331,36 @@ class TicketingSettingsController extends Controller
->orderBy('name') ->orderBy('name')
->get(); ->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() ? collect()
: EmailConnection::when($visibleGroupIds !== null, : EmailConnection::when($visibleGroupIds !== null,
fn($q) => $q->whereIn('group_id', $visibleGroupIds)) 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', [ return Inertia::render('Ticketing/Settings', [
'activeTab' => $tab, 'activeTab' => $tab,
@@ -913,12 +938,43 @@ class TicketingSettingsController extends Controller
$connection->update([ $connection->update([
'type' => $request->type, 'type' => $request->type,
'active' => $request->boolean('active', true), 'active' => $request->boolean('active', true),
'config' => $request->input('config', $connection->config), 'config' => $this->mergedConnectionConfig($request, $connection),
]); ]);
return back()->with('success', 'Email connection updated.'); 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) public function destroyEmailConnection(EmailConnection $connection)
{ {
$this->requireAgentAccess(); $this->requireAgentAccess();

View File

@@ -41,8 +41,15 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
// ── Settings (must be before /{ticket} wildcard) ────────────────────── // ── Settings (must be before /{ticket} wildcard) ──────────────────────
Route::middleware('permission:ticketing.settings')->group(function () { Route::middleware('permission:ticketing.settings')->group(function () {
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings'); 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']) 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::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update'); Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy'); Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');