Give the mailboxes a page, and the support sender its own capability
parent
a12ab148b3
commit
4211f3dfab
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The sending addresses, and which kind of mail leaves from which.
|
||||
*
|
||||
* The server sits at the top because there is one of it; the mailboxes are a
|
||||
* list because there are several; the mapping is last because it only makes
|
||||
* sense once both exist.
|
||||
*
|
||||
* The test-send button is a later task's job. This page only shows and edits
|
||||
* what already exists — a button that cannot actually test is a promise the
|
||||
* page does not keep (the same reasoning the secrets page already applies).
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Mail extends Component
|
||||
{
|
||||
public string $host = '';
|
||||
|
||||
public int|string $port = 587;
|
||||
|
||||
public string $encryption = 'tls';
|
||||
|
||||
/** @var array<string, string> purpose => mailbox key */
|
||||
public array $purposes = [];
|
||||
|
||||
/**
|
||||
* Whether credentials can be stored at all on this installation.
|
||||
*
|
||||
* Public so the page can say it rather than throwing on the first password
|
||||
* a mailbox tries to decrypt — the same courtesy the secrets page already
|
||||
* extends. A missing SECRETS_KEY is a setup state, not an error.
|
||||
*/
|
||||
public bool $usable = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$this->host = (string) Settings::get('mail.host', '');
|
||||
$this->port = (int) Settings::get('mail.port', 587);
|
||||
$this->encryption = (string) Settings::get('mail.encryption', 'tls');
|
||||
|
||||
foreach (MailPurpose::ALL as $purpose) {
|
||||
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
|
||||
}
|
||||
}
|
||||
|
||||
public function saveServer(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
// Rules on the ACTION, not on the property: a #[Validate] attribute on
|
||||
// a Livewire property applies class-wide, and savePurposes() below
|
||||
// would drag these along.
|
||||
$this->validate([
|
||||
'host' => ['required', 'string', 'max:255'],
|
||||
'port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'encryption' => ['required', 'in:tls,ssl,none'],
|
||||
]);
|
||||
|
||||
Settings::set('mail.host', $this->host);
|
||||
Settings::set('mail.port', (int) $this->port);
|
||||
Settings::set('mail.encryption', $this->encryption);
|
||||
|
||||
$this->dispatch('notify', message: __('mail_settings.server_saved'));
|
||||
}
|
||||
|
||||
public function savePurposes(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
// system is the fallback, so it is the one that may not be empty —
|
||||
// there is nothing left to fall back to.
|
||||
$this->validate(
|
||||
['purposes.system' => ['required', 'string']],
|
||||
['purposes.system.required' => __('mail_settings.system_required')],
|
||||
);
|
||||
|
||||
foreach (MailPurpose::ALL as $purpose) {
|
||||
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->usable = app(SecretCipher::class)->isUsable();
|
||||
|
||||
return view('livewire.admin.mail', [
|
||||
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
|
||||
'purposeList' => MailPurpose::ALL,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Editing a mailbox, in a modal (R20).
|
||||
*
|
||||
* A modal is reachable WITHOUT the page's route middleware, so it authorises
|
||||
* itself and re-reads the record instead of trusting a property the browser
|
||||
* hydrated. save() re-authorises independently of mount() for the same reason
|
||||
* Secrets::guard() checks on every action rather than once: a Livewire action
|
||||
* is reachable by anyone who can post to /livewire/update, and a mount that
|
||||
* succeeded earlier in the session proves nothing about the request now.
|
||||
*/
|
||||
class EditMailbox extends ModalComponent
|
||||
{
|
||||
public string $uuid = '';
|
||||
|
||||
public string $address = '';
|
||||
|
||||
public string $displayName = '';
|
||||
|
||||
public string $username = '';
|
||||
|
||||
/** Always starts empty: a stored password never travels to the browser. */
|
||||
public string $password = '';
|
||||
|
||||
public bool $noReply = false;
|
||||
|
||||
public bool $active = true;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
$this->uuid = $box->uuid;
|
||||
$this->address = $box->address;
|
||||
$this->displayName = (string) $box->display_name;
|
||||
$this->username = (string) $box->username;
|
||||
$this->noReply = $box->no_reply;
|
||||
$this->active = $box->active;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$this->validate([
|
||||
'address' => ['required', 'email', 'max:255'],
|
||||
'displayName' => ['nullable', 'string', 'max:255'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
|
||||
|
||||
$box->address = $this->address;
|
||||
$box->display_name = $this->displayName ?: null;
|
||||
$box->username = $this->username ?: null;
|
||||
$box->no_reply = $this->noReply;
|
||||
$box->active = $this->active;
|
||||
|
||||
// Empty means "leave it alone", not "delete it" — otherwise every edit
|
||||
// of an address would silently drop the password.
|
||||
if ($this->password !== '') {
|
||||
$box->password = $this->password;
|
||||
$box->last_verified_at = null;
|
||||
}
|
||||
|
||||
$box->save();
|
||||
|
||||
$this->password = '';
|
||||
$this->dispatch('notify', message: __('mail_settings.saved'));
|
||||
$this->dispatch('mailbox-saved');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.edit-mailbox');
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ final class Navigation
|
|||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
]],
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
['admin.secrets', 'lock', 'secrets', 'secrets.manage'],
|
||||
['admin.settings', 'settings', 'settings', null],
|
||||
]],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Its own capability, not secrets.manage.
|
||||
*
|
||||
* Whoever keeps the support sender working does not thereby need the Stripe key
|
||||
* and the DNS token. Splitting it is the difference between "can change an
|
||||
* address" and "can move money".
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$permission = Permission::findOrCreate('mail.manage', 'web');
|
||||
|
||||
foreach (['Owner', 'Admin', 'Support'] as $role) {
|
||||
Role::findByName($role, 'web')->givePermissionTo($permission);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Permission::where('name', 'mail.manage')->where('guard_name', 'web')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ return [
|
|||
'maintenance' => 'Wartungen',
|
||||
'vpn' => 'VPN',
|
||||
'revenue' => 'Umsatz',
|
||||
'mail' => 'E-Mail',
|
||||
'secrets' => 'Zugangsdaten',
|
||||
'settings' => 'Einstellungen',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'E-Mail',
|
||||
'subtitle' => 'Absenderadressen und der Server, über den sie verschickt werden.',
|
||||
|
||||
'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Postfach-Passwörter gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.',
|
||||
|
||||
'server_title' => 'Mailserver',
|
||||
'server_hint' => 'Gilt für alle Postfächer — sie liegen auf demselben Server.',
|
||||
'host' => 'Server',
|
||||
'port' => 'Port',
|
||||
'encryption' => 'Verschlüsselung',
|
||||
'encryption_none' => 'Keine',
|
||||
'save' => 'Speichern',
|
||||
'server_saved' => 'Serverdaten gespeichert.',
|
||||
|
||||
'boxes_title' => 'Postfächer',
|
||||
'address' => 'Adresse',
|
||||
'display_name' => 'Anzeigename',
|
||||
'username' => 'SMTP-Benutzer',
|
||||
'username_hint' => 'Leer lassen, wenn er der Adresse entspricht.',
|
||||
'password' => 'Passwort',
|
||||
'password_hint' => 'Wird verschlüsselt gespeichert und nie wieder vollständig angezeigt. Leer lassen, um das bisherige zu behalten.',
|
||||
'no_reply' => 'Keine Antworten (kein Reply-To)',
|
||||
'no_reply_hint' => 'Ein „no-reply", auf das man antworten kann, ist eine Lüge im Absender.',
|
||||
'active' => 'Aktiv',
|
||||
'edit' => 'Bearbeiten',
|
||||
'saved' => 'Postfach gespeichert.',
|
||||
'never_verified' => 'Noch nicht bestätigt',
|
||||
'last_verified' => 'Zuletzt bestätigt',
|
||||
|
||||
'purposes_title' => 'Wer verschickt was',
|
||||
'purposes_hint' => 'Ein Zweck ohne Postfach verschickt über „System".',
|
||||
'purpose' => [
|
||||
'maintenance' => 'Wartungsankündigungen',
|
||||
'provisioning' => 'Bereitstellung und Bestellbestätigung',
|
||||
'support' => 'Antworten auf Support-Anfragen',
|
||||
'billing' => 'Rechnungen und Zahlungserinnerungen',
|
||||
'system' => 'System (Rückfall für alles Übrige)',
|
||||
],
|
||||
'purposes_saved' => 'Zuordnung gespeichert.',
|
||||
'system_required' => '„System" muss ein Postfach haben — es ist der Rückfall für alle anderen.',
|
||||
];
|
||||
|
|
@ -21,6 +21,7 @@ return [
|
|||
'maintenance' => 'Maintenance',
|
||||
'vpn' => 'VPN',
|
||||
'revenue' => 'Revenue',
|
||||
'mail' => 'Email',
|
||||
'secrets' => 'Credentials',
|
||||
'settings' => 'Settings',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Email',
|
||||
'subtitle' => 'Sending addresses, and the server they go out through.',
|
||||
|
||||
'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own, no mailbox passwords are stored here — deliberately, because APP_KEY is rotated as routine maintenance.',
|
||||
|
||||
'server_title' => 'Mail server',
|
||||
'server_hint' => 'Applies to every mailbox — they all sit on the same server.',
|
||||
'host' => 'Server',
|
||||
'port' => 'Port',
|
||||
'encryption' => 'Encryption',
|
||||
'encryption_none' => 'None',
|
||||
'save' => 'Save',
|
||||
'server_saved' => 'Server settings saved.',
|
||||
|
||||
'boxes_title' => 'Mailboxes',
|
||||
'address' => 'Address',
|
||||
'display_name' => 'Display name',
|
||||
'username' => 'SMTP user',
|
||||
'username_hint' => 'Leave empty if it matches the address.',
|
||||
'password' => 'Password',
|
||||
'password_hint' => 'Stored encrypted and never shown in full again. Leave empty to keep the current one.',
|
||||
'no_reply' => 'No replies (no Reply-To)',
|
||||
'no_reply_hint' => 'A "no-reply" that can be replied to is a lie in the sender.',
|
||||
'active' => 'Active',
|
||||
'edit' => 'Edit',
|
||||
'saved' => 'Mailbox saved.',
|
||||
'never_verified' => 'Not yet verified',
|
||||
'last_verified' => 'Last verified',
|
||||
|
||||
'purposes_title' => 'Who sends what',
|
||||
'purposes_hint' => 'A purpose with no mailbox sends through "System".',
|
||||
'purpose' => [
|
||||
'maintenance' => 'Maintenance announcements',
|
||||
'provisioning' => 'Provisioning and order confirmation',
|
||||
'support' => 'Replies to support requests',
|
||||
'billing' => 'Invoices and payment reminders',
|
||||
'system' => 'System (fallback for everything else)',
|
||||
],
|
||||
'purposes_saved' => 'Mapping saved.',
|
||||
'system_required' => '"System" must have a mailbox — it is the fallback for all the others.',
|
||||
];
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
||||
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
||||
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
];
|
||||
$body = $icons[$name] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_settings.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
@if (! $usable)
|
||||
<x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
{{-- 1. The server. One of it, so one card. --}}
|
||||
<x-ui.card>
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.server_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.server_hint') }}</p>
|
||||
|
||||
<div class="mt-5 grid gap-4 sm:grid-cols-3">
|
||||
<x-ui.input name="host" wire:model="host" :label="__('mail_settings.host')" />
|
||||
<x-ui.input name="port" type="number" wire:model="port" :label="__('mail_settings.port')" />
|
||||
<div>
|
||||
<label for="encryption" class="text-sm font-medium text-ink">{{ __('mail_settings.encryption') }}</label>
|
||||
<select id="encryption" wire:model="encryption"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
<option value="tls">TLS</option>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="none">{{ __('mail_settings.encryption_none') }}</option>
|
||||
</select>
|
||||
@error('encryption')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-ui.button wire:click="saveServer" variant="primary" size="md" class="mt-5">
|
||||
{{ __('mail_settings.save') }}
|
||||
</x-ui.button>
|
||||
</x-ui.card>
|
||||
|
||||
{{-- 2. The mailboxes. NO input field inside a <td> — R20. --}}
|
||||
<x-ui.card>
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.boxes_title') }}</h2>
|
||||
|
||||
<table class="mt-5 w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left">
|
||||
<th class="lbl pb-2">{{ __('mail_settings.address') }}</th>
|
||||
<th class="lbl pb-2">{{ __('mail_settings.last_verified') }}</th>
|
||||
<th class="lbl pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($mailboxes as $box)
|
||||
<tr wire:key="mailbox-{{ $box->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="py-3">
|
||||
<span class="font-medium text-ink">{{ $box->address }}</span>
|
||||
<span class="ml-2 text-muted">{{ $box->key }}</span>
|
||||
</td>
|
||||
<td class="py-3 text-muted">
|
||||
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
{{-- Edit only (R18: icon beside its text, single line).
|
||||
The test-send button belongs to a later task —
|
||||
shipping one that cannot yet test anything is a
|
||||
promise this page does not keep. --}}
|
||||
<x-ui.button size="sm" variant="ghost"
|
||||
x-on:click="$dispatch('openModal', { component: 'edit-mailbox', arguments: { uuid: '{{ $box->uuid }}' } })">
|
||||
<x-ui.icon name="pencil" class="size-4" />{{ __('mail_settings.edit') }}
|
||||
</x-ui.button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-ui.card>
|
||||
|
||||
{{-- 3. The mapping. One <select> per row — R20's stated exception. --}}
|
||||
<x-ui.card>
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.purposes_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.purposes_hint') }}</p>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
@foreach ($purposeList as $purpose)
|
||||
<div class="flex items-center justify-between gap-6 border-b border-line py-2">
|
||||
<span class="text-sm text-ink">{{ __('mail_settings.purpose.'.$purpose) }}</span>
|
||||
<select wire:model="purposes.{{ $purpose }}"
|
||||
class="w-64 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
<option value="">—</option>
|
||||
@foreach ($mailboxes as $box)
|
||||
<option value="{{ $box->key }}">{{ $box->address }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@error('purposes.system')<p class="mt-3 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
|
||||
<x-ui.button wire:click="savePurposes" variant="primary" size="md" class="mt-5">
|
||||
{{ __('mail_settings.save') }}
|
||||
</x-ui.button>
|
||||
</x-ui.card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('mail_settings.edit') }}</h3>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<x-ui.input name="address" type="email" wire:model="address" :label="__('mail_settings.address')" />
|
||||
|
||||
<x-ui.input name="displayName" wire:model="displayName" :label="__('mail_settings.display_name')" />
|
||||
|
||||
<x-ui.input name="username" wire:model="username"
|
||||
:label="__('mail_settings.username')" :hint="__('mail_settings.username_hint')" />
|
||||
|
||||
<x-ui.input name="password" type="password" autocomplete="off" wire:model="password"
|
||||
:label="__('mail_settings.password')" :hint="__('mail_settings.password_hint')" />
|
||||
|
||||
<div class="space-y-2 rounded-lg border border-line bg-surface-2 p-4">
|
||||
<x-ui.checkbox name="noReply" wire:model="noReply" :label="__('mail_settings.no_reply')" />
|
||||
<p class="pl-7 text-xs text-muted">{{ __('mail_settings.no_reply_hint') }}</p>
|
||||
|
||||
<x-ui.checkbox name="active" wire:model="active" :label="__('mail_settings.active')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('mail_settings.save') }}</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,6 +36,7 @@ Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
|
|||
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
|
||||
Route::get('/vpn', Admin\Vpn::class)->name('vpn');
|
||||
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
|
||||
Route::get('/mail', Admin\Mail::class)->name('mail');
|
||||
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
|
||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Mail as MailPage;
|
||||
use App\Livewire\EditMailbox;
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
|
||||
// Blank slate, not the migration's seeded five: every other file in this
|
||||
// directory does the same (see tests/Pest.php). Two of the tests below
|
||||
// pin a mailbox to the key 'support' and another to 'no-reply' — both
|
||||
// collide with mailboxes.key's unique constraint against the seeded
|
||||
// baseline RefreshDatabase leaves in place. Clearing keeps this file
|
||||
// deterministic regardless of what the seed migration happens to contain.
|
||||
clearMailboxSeed();
|
||||
});
|
||||
|
||||
it('is closed to an operator without mail.manage', function () {
|
||||
$user = User::factory()->operator('Read-only')->create();
|
||||
|
||||
Livewire::actingAs($user)->test(MailPage::class)->assertForbidden();
|
||||
});
|
||||
|
||||
it('opens for an operator who has it', function () {
|
||||
$user = User::factory()->operator('Owner')->create();
|
||||
|
||||
Livewire::actingAs($user)->test(MailPage::class)->assertOk()->assertSet('usable', true);
|
||||
});
|
||||
|
||||
it('grants mail.manage to Admin and Support as well as Owner', function () {
|
||||
// The migration hands the capability to three roles, not one. Nothing
|
||||
// above proves the other two actually received it — an Owner-only test
|
||||
// would stay green even if the migration's role loop silently lost a name.
|
||||
expect(User::factory()->operator('Admin')->create()->can('mail.manage'))->toBeTrue()
|
||||
->and(User::factory()->operator('Support')->create()->can('mail.manage'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('is not opened by secrets.manage alone — that is the point of splitting them', function () {
|
||||
// Billing holds secrets-adjacent capabilities but has no business changing
|
||||
// the support sender, and vice versa. If this ever passes, the two
|
||||
// capabilities have quietly been merged again.
|
||||
$user = User::factory()->operator('Billing')->create();
|
||||
|
||||
expect($user->can('mail.manage'))->toBeFalse();
|
||||
|
||||
Livewire::actingAs($user)->test(MailPage::class)->assertForbidden();
|
||||
});
|
||||
|
||||
it('edits a mailbox in a modal, never in the row (R20)', function () {
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
|
||||
$blade = file_get_contents(resource_path('views/livewire/admin/mail.blade.php'));
|
||||
|
||||
expect($blade)->not->toMatch('/<td[^>]*>(?:(?!<\/td>).)*<input/s')
|
||||
->and($blade)->toContain('openModal');
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->set('address', 'neu@clupilot.com')
|
||||
->call('save');
|
||||
|
||||
expect($box->fresh()->address)->toBe('neu@clupilot.com');
|
||||
});
|
||||
|
||||
it('never hands a stored password back to the browser', function () {
|
||||
$box = Mailbox::factory()->create(['password' => 'streng-geheim']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->assertSet('password', '')
|
||||
->assertDontSee('streng-geheim');
|
||||
});
|
||||
|
||||
it('keeps the old password when the field is left empty', function () {
|
||||
$box = Mailbox::factory()->create(['password' => 'bleibt']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->set('address', 'neu@clupilot.com')
|
||||
->call('save');
|
||||
|
||||
expect($box->fresh()->password)->toBe('bleibt');
|
||||
});
|
||||
|
||||
it('says so when SECRETS_KEY is missing, instead of crashing on the first password', function () {
|
||||
config()->set('admin_access.secrets_key', '');
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->assertOk()
|
||||
->assertSet('usable', false)
|
||||
->assertSee(__('mail_settings.no_key'));
|
||||
});
|
||||
|
||||
it('refuses to leave the system purpose empty, because it is the fallback', function () {
|
||||
Mailbox::factory()->create(['key' => 'no-reply']);
|
||||
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('purposes.system', '')
|
||||
->call('savePurposes')
|
||||
->assertHasErrors('purposes.system');
|
||||
|
||||
expect(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply');
|
||||
});
|
||||
|
||||
// --- Coverage beyond the brief's list: behaviours the page and modal claim
|
||||
// but the tests above never exercise. Each was verified to fail for the
|
||||
// right reason before the implementation existed, and again by mutation
|
||||
// afterwards (see the task report).
|
||||
|
||||
it('lists every mailbox on the page', function () {
|
||||
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->assertSee('support@clupilot.com')
|
||||
->assertSee('support');
|
||||
});
|
||||
|
||||
it('shows the currently configured mailbox for each purpose', function () {
|
||||
Mailbox::factory()->create(['key' => 'support']);
|
||||
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->assertSet('purposes.support', 'support');
|
||||
});
|
||||
|
||||
it('saves the mail server settings', function () {
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('host', 'smtp.example.com')
|
||||
->set('port', 2525)
|
||||
->set('encryption', 'ssl')
|
||||
->call('saveServer')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Settings::get('mail.host'))->toBe('smtp.example.com')
|
||||
->and(Settings::get('mail.port'))->toBe(2525)
|
||||
->and(Settings::get('mail.encryption'))->toBe('ssl');
|
||||
});
|
||||
|
||||
it('rejects an empty mail server host', function () {
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('host', '')
|
||||
->call('saveServer')
|
||||
->assertHasErrors('host');
|
||||
});
|
||||
|
||||
it('saves the purpose-to-mailbox mapping', function () {
|
||||
Mailbox::factory()->create(['key' => 'support']);
|
||||
Mailbox::factory()->create(['key' => 'no-reply']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('purposes.system', 'no-reply')
|
||||
->set('purposes.support', 'support')
|
||||
->call('savePurposes')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Settings::get(MailPurpose::settingKey(MailPurpose::SUPPORT)))->toBe('support')
|
||||
->and(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply');
|
||||
});
|
||||
|
||||
it('re-checks mail.manage on every page action, not only at mount', function () {
|
||||
// mount() alone would pass every test above: an Owner has the capability
|
||||
// for the whole test, so nothing so far proves saveServer()/savePurposes()
|
||||
// carry their own authorize() rather than relying on mount() having run
|
||||
// once. Swap the authenticated user between mount and the action — same
|
||||
// shape as SecretsPageTest's "refuses every action while locked", applied
|
||||
// to the capability gate instead of the password gate.
|
||||
$owner = User::factory()->operator('Owner')->create();
|
||||
$billing = User::factory()->operator('Billing')->create();
|
||||
|
||||
// A fresh mount per action, like SecretsPageTest's equivalent: a 403
|
||||
// response carries no usable Livewire snapshot, so a second ->call() on
|
||||
// the same already-forbidden instance fails on the snapshot itself
|
||||
// rather than on the authorization this test means to check.
|
||||
foreach (['saveServer', 'savePurposes'] as $action) {
|
||||
$page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk();
|
||||
|
||||
Livewire::actingAs($billing);
|
||||
|
||||
$page->call($action)->assertForbidden();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-checks mail.manage on modal save, not only at mount', function () {
|
||||
// Same gap as above, for the modal: mount() re-reads the record instead of
|
||||
// trusting the browser (R20), but that is a different guarantee from
|
||||
// save() re-authorising instead of trusting a mount that already happened.
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
$owner = User::factory()->operator('Owner')->create();
|
||||
$billing = User::factory()->operator('Billing')->create();
|
||||
|
||||
$modal = Livewire::actingAs($owner)->test(EditMailbox::class, ['uuid' => $box->uuid])->assertOk();
|
||||
|
||||
Livewire::actingAs($billing);
|
||||
|
||||
$modal->set('address', 'neu@clupilot.com')->call('save')->assertForbidden();
|
||||
});
|
||||
|
||||
it('refuses to edit a mailbox without mail.manage, even though the modal bypasses the page route', function () {
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Billing')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('updates the password when a new one is entered', function () {
|
||||
// The mirror of "keeps the old password when the field is left empty":
|
||||
// that test alone would stay green even if save() stopped writing the
|
||||
// password entirely, since "never changes" also satisfies "kept the old
|
||||
// one". This proves the other half — a new value actually lands.
|
||||
$box = Mailbox::factory()->create(['password' => 'alt']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->set('password', 'neu-und-sicher')
|
||||
->call('save');
|
||||
|
||||
expect($box->fresh()->password)->toBe('neu-und-sicher');
|
||||
});
|
||||
|
||||
it('clears last_verified_at when the password changes, since it no longer proves anything', function () {
|
||||
$box = Mailbox::factory()->create(['password' => 'alt', 'last_verified_at' => now()]);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->set('password', 'neu-und-sicher')
|
||||
->call('save');
|
||||
|
||||
expect($box->fresh()->last_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('saves display name, username, no-reply and active together', function () {
|
||||
$box = Mailbox::factory()->create(['no_reply' => false, 'active' => true]);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->set('displayName', 'Support-Team')
|
||||
->set('username', 'smtp-user')
|
||||
->set('noReply', true)
|
||||
->set('active', false)
|
||||
->call('save');
|
||||
|
||||
$box->refresh();
|
||||
|
||||
expect($box->display_name)->toBe('Support-Team')
|
||||
->and($box->username)->toBe('smtp-user')
|
||||
->and($box->no_reply)->toBeTrue()
|
||||
->and($box->active)->toBeFalse();
|
||||
});
|
||||
|
||||
it('pre-fills the form from the existing record', function () {
|
||||
$box = Mailbox::factory()->create([
|
||||
'address' => 'support@clupilot.com',
|
||||
'display_name' => 'Support-Team',
|
||||
'username' => 'smtp-support',
|
||||
'no_reply' => true,
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(EditMailbox::class, ['uuid' => $box->uuid])
|
||||
->assertSet('address', 'support@clupilot.com')
|
||||
->assertSet('displayName', 'Support-Team')
|
||||
->assertSet('username', 'smtp-support')
|
||||
->assertSet('noReply', true)
|
||||
->assertSet('active', false);
|
||||
});
|
||||
Loading…
Reference in New Issue