mail: Anmeldelink an die Adresse der Mail statt „in Ihrem Konto“
Roadmap 6.17. Veröffentlichungs- und Ablehnungsmails zu API-Meldungen gehen an Pressekontakt oder Firmen-E-Mail — Empfänger ohne (bekanntes) Konto. Die Mail sagte „in Ihrem Konto … mit Passwort oder Magic-Link“, gelesen wurde „geht nicht“. Jetzt ein Kasten mit der Adresse und dem Knopf „Anmeldelink anfordern“: signierter Link (30 Tage, Adresse verschlüsselt) auf /zugang, ein Klick schickt den Anmeldelink, die Anmeldung landet auf der Meldung. Ablehnung zu API-Meldungen: nur ansehen, neu eingereicht wird auf dem ursprünglichen Weg. Kunden mit Konto: Knopf „Im pressekonto öffnen“. Stufen 2 und 3 des Trichters messen die neue Seite mit. Changelog: Verbessert Public: Wer eine Veröffentlichungs- oder Ablehnungsmail bekommt, erhält mit einem Klick einen Anmeldelink an genau diese Adresse – ohne Konto und ohne Passwort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c2f44f42c3
commit
1efb30f9ce
9 changed files with 442 additions and 23 deletions
|
|
@ -7,6 +7,7 @@ use App\Enums\EmailNotificationType;
|
||||||
use App\Mail\Concerns\Unsubscribable;
|
use App\Mail\Concerns\Unsubscribable;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
|
@ -39,6 +40,16 @@ class PressReleasePublished extends Mailable
|
||||||
*/
|
*/
|
||||||
public ?string $manageNewsroomUrl = null;
|
public ?string $manageNewsroomUrl = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfänger ohne Konto (API-Meldung an Pressekontakt oder Firmen-E-Mail,
|
||||||
|
* Roadmap 6.17): Adresse, an die diese Mail geht, und der Link, der mit
|
||||||
|
* einem Klick einen Anmeldelink an genau diese Adresse schickt. Null für
|
||||||
|
* Kunden, die selbst eingereicht haben — die kennen ihr Konto.
|
||||||
|
*/
|
||||||
|
public ?string $accessEmail = null;
|
||||||
|
|
||||||
|
public ?string $accessUrl = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly User $user,
|
public readonly User $user,
|
||||||
public readonly PressRelease $pressRelease,
|
public readonly PressRelease $pressRelease,
|
||||||
|
|
@ -72,6 +83,8 @@ class PressReleasePublished extends Mailable
|
||||||
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
||||||
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
||||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||||
|
$this->accessEmail = trim($recipientEmail);
|
||||||
|
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailPublish);
|
||||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
|
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
|
||||||
} else {
|
} else {
|
||||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
|
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Enums\Portal;
|
||||||
use App\Mail\Concerns\Unsubscribable;
|
use App\Mail\Concerns\Unsubscribable;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
|
@ -29,6 +30,16 @@ class PressReleaseRejected extends Mailable
|
||||||
*/
|
*/
|
||||||
public string $recipientName;
|
public string $recipientName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfänger ohne Konto (API-Meldung, Roadmap 6.17): Adresse dieser Mail
|
||||||
|
* und Link, der einen Anmeldelink an sie schickt. Die Meldung kam über
|
||||||
|
* einen Verteiler — überarbeitet wird sie dort, nicht über unser Konto
|
||||||
|
* des Einlieferers; der Link führt deshalb nur zum Ansehen.
|
||||||
|
*/
|
||||||
|
public ?string $accessEmail = null;
|
||||||
|
|
||||||
|
public ?string $accessUrl = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly User $user,
|
public readonly User $user,
|
||||||
public readonly PressRelease $pressRelease,
|
public readonly PressRelease $pressRelease,
|
||||||
|
|
@ -53,6 +64,8 @@ class PressReleaseRejected extends Mailable
|
||||||
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
||||||
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
||||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||||
|
$this->accessEmail = trim($recipientEmail);
|
||||||
|
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailReject);
|
||||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
||||||
} else {
|
} else {
|
||||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
||||||
|
|
|
||||||
63
app/Services/Auth/MailAccessLink.php
Normal file
63
app/Services/Auth/MailAccessLink.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Anmeldelink an diese Adresse“ aus einer Kundenmail (Roadmap 6.17).
|
||||||
|
*
|
||||||
|
* Veröffentlichungs- und Ablehnungsmails zu API-Meldungen gehen an den
|
||||||
|
* Pressekontakt oder die Firmen-E-Mail — Empfänger, die aus ihrer Sicht kein
|
||||||
|
* Konto haben. Der Link führt auf eine Seite, die mit einem Klick einen
|
||||||
|
* Anmeldelink an genau die Adresse schickt, an die die Mail ging, und nach
|
||||||
|
* der Anmeldung auf der Meldung landet.
|
||||||
|
*
|
||||||
|
* Der Link meldet niemanden an: Er löst nur einen Anmeldelink an eine
|
||||||
|
* Adresse aus, die ihn ohnehin bekommen könnte (`/anmeldelink`). Signiert und
|
||||||
|
* befristet, die Adresse verschlüsselt — sie steht nicht lesbar in der URL,
|
||||||
|
* in Logs oder in der Webanalyse. `src` und `pr` stehen zusätzlich im Klartext
|
||||||
|
* für die Klickmessung (RecordMailLinkClick).
|
||||||
|
*/
|
||||||
|
class MailAccessLink
|
||||||
|
{
|
||||||
|
public const int VALID_DAYS = 30;
|
||||||
|
|
||||||
|
public function url(string $email, PressRelease $pressRelease, ConversionSource $source): string
|
||||||
|
{
|
||||||
|
return URL::temporarySignedRoute('mail-access', now()->addDays(self::VALID_DAYS), [
|
||||||
|
't' => Crypt::encryptString((string) json_encode([
|
||||||
|
'e' => mb_strtolower(trim($email)),
|
||||||
|
'p' => $pressRelease->getKey(),
|
||||||
|
])),
|
||||||
|
'src' => $source->value,
|
||||||
|
'pr' => $pressRelease->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{email: string, press_release_id: int}|null
|
||||||
|
*/
|
||||||
|
public function decode(?string $token): ?array
|
||||||
|
{
|
||||||
|
if (blank($token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode(Crypt::decryptString((string) $token), true);
|
||||||
|
} catch (DecryptException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($data) || ! is_string($data['e'] ?? null) || ! filter_var($data['e'], FILTER_VALIDATE_EMAIL) || (int) ($data['p'] ?? 0) <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['email' => $data['e'], 'press_release_id' => (int) $data['p']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,25 +24,37 @@
|
||||||
@endif
|
@endif
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{{-- 2) Verwaltung im Konto (Login-Bereich), inkl. Hinweis auf die
|
@if ($accessUrl)
|
||||||
Anmeldung per Passwort oder Magic-Link. --}}
|
{{-- 2) Empfänger ohne Konto (API-Meldung, Roadmap 6.17): ein Klick
|
||||||
<p>
|
schickt einen Anmeldelink an genau diese Adresse. Vorher stand
|
||||||
Bearbeiten und verwalten können Sie die Pressemitteilung in Ihrem Konto:<br>
|
hier „in Ihrem Konto … mit Passwort oder Magic-Link“ — wer kein
|
||||||
<a href="{{ $showUrl }}">Pressemitteilung im pressekonto öffnen</a>
|
Konto kennt, las daraus „geht nicht“. --}}
|
||||||
</p>
|
<div class="meta">
|
||||||
<p style="font-size: 12.5px; color: #6b7280;">
|
<p><strong>Etwas ändern oder die Aufrufe sehen?</strong></p>
|
||||||
Hinweis: Dieser Link führt in Ihren persönlichen Login-Bereich. Die Anmeldung ist
|
<p>
|
||||||
dort mit Ihrem Passwort oder per Magic-Link (Anmelde-Link per E-Mail) möglich.
|
Dafür brauchen Sie weder ein Konto noch ein Passwort. Wir schicken einen Anmeldelink an
|
||||||
</p>
|
<strong>{{ $accessEmail }}</strong>, die Adresse, an die diese Mail ging. Danach können Sie
|
||||||
|
die Meldung korrigieren, die Aufrufe Tag für Tag sehen{{ $pressRelease->company ? ' und den Newsroom von '.$pressRelease->company->name.' pflegen (Beschreibung, Logo, Pressekontakte)' : '' }}.
|
||||||
{{-- 3) Verwaltungszugang zum Newsroom (Roadmap 1b.4, E4): ein Hinweis
|
Das ist kostenlos.
|
||||||
am Ende, kein Angebot — der Zugang ist kostenlos. --}}
|
</p>
|
||||||
@if ($manageNewsroomUrl)
|
<x-emails.button :url="$accessUrl">Anmeldelink anfordern</x-emails.button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
{{-- 2) Kunden mit eigenem Konto: direkt in den Kundenbereich. --}}
|
||||||
|
<x-emails.button :url="$showUrl">Im pressekonto öffnen</x-emails.button>
|
||||||
<p style="font-size: 12.5px; color: #6b7280;">
|
<p style="font-size: 12.5px; color: #6b7280;">
|
||||||
Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen —
|
Dort sehen Sie die Aufrufe der Meldung und können sie korrigieren.
|
||||||
Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:<br>
|
|
||||||
<a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten und Aufrufe einsehen</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{{-- 3) Verwaltungszugang zum Newsroom (Roadmap 1b.4, E4): ein Hinweis
|
||||||
|
am Ende, kein Angebot — der Zugang ist kostenlos. --}}
|
||||||
|
@if ($manageNewsroomUrl)
|
||||||
|
<p style="font-size: 12.5px; color: #6b7280;">
|
||||||
|
Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen —
|
||||||
|
Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:<br>
|
||||||
|
<a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten und Aufrufe einsehen</a>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- 4) Erstquelle gegen Kopie (Roadmap 1d, Anhang 17.09.2026): nur bei
|
{{-- 4) Erstquelle gegen Kopie (Roadmap 1d, Anhang 17.09.2026): nur bei
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,20 @@
|
||||||
<p>Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.</p>
|
<p>Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-emails.button :url="$editUrl">Pressemitteilung bearbeiten</x-emails.button>
|
@if ($accessUrl)
|
||||||
|
{{-- Empfänger ohne Konto (API-Meldung, Roadmap 6.17): ansehen ja,
|
||||||
|
überarbeitet und neu eingereicht wird über den Verteiler, über
|
||||||
|
den die Meldung kam. --}}
|
||||||
|
<p>
|
||||||
|
Meldung und Ablehnungsgrund können Sie ohne Konto und ohne Passwort ansehen: Wir schicken einen
|
||||||
|
Anmeldelink an <strong>{{ $accessEmail }}</strong>, die Adresse, an die diese Mail ging.
|
||||||
|
Eine überarbeitete Fassung reichen Sie bitte auf demselben Weg ein wie diese Meldung.
|
||||||
|
</p>
|
||||||
|
<x-emails.button :url="$accessUrl">Anmeldelink anfordern</x-emails.button>
|
||||||
|
@else
|
||||||
|
<p>Sie können die Meldung überarbeiten und erneut zur Prüfung einreichen.</p>
|
||||||
|
<x-emails.button :url="$editUrl">Pressemitteilung bearbeiten</x-emails.button>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Roadmap 1d.2 (E6: die Ablehnungsstrecke bleibt frei von Angeboten):
|
{{-- Roadmap 1d.2 (E6: die Ablehnungsstrecke bleibt frei von Angeboten):
|
||||||
ein Hinweis, was eine Mitteilung braucht, und der Weg zur Freigabe. --}}
|
ein Hinweis, was eine Mitteilung braucht, und der Weg zur Freigabe. --}}
|
||||||
|
|
|
||||||
142
resources/views/livewire/auth/mail-access.blade.php
Normal file
142
resources/views/livewire/auth/mail-access.blade.php
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ConversionEventType;
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\Auth\ContactAccessService;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
|
use App\Services\Company\NewsroomClaimService;
|
||||||
|
use App\Services\Conversion\ConversionRecorder;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldelink an die Adresse, an die eine Kundenmail ging (Roadmap 6.17).
|
||||||
|
*
|
||||||
|
* Erreichbar nur über den signierten Link aus der Veröffentlichungs- oder
|
||||||
|
* Ablehnungsmail (MailAccessLink). Ein Klick schickt den Anmeldelink; nach
|
||||||
|
* der Anmeldung landet der Empfänger auf der Meldung. Die Seite zeigt die
|
||||||
|
* Adresse nur maskiert.
|
||||||
|
*/
|
||||||
|
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Ohne Passwort anmelden', 'eyebrow' => 'Zugang zu Ihrer Pressemitteilung', 'topRightLabel' => 'Lieber mit Passwort?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
||||||
|
#[Locked]
|
||||||
|
public string $token = '';
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public string $source = '';
|
||||||
|
|
||||||
|
public string $maskedEmail = '';
|
||||||
|
|
||||||
|
public string $title = '';
|
||||||
|
|
||||||
|
public bool $sent = false;
|
||||||
|
|
||||||
|
public function mount(MailAccessLink $links, ConversionRecorder $recorder): mixed
|
||||||
|
{
|
||||||
|
$data = $links->decode(request()->query('t'));
|
||||||
|
$pressRelease = $data === null ? null : $this->pressRelease($data['press_release_id']);
|
||||||
|
|
||||||
|
abort_if($pressRelease === null, 404);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($user !== null && mb_strtolower((string) $user->email) === $data['email'] && $user->can('view', $pressRelease)) {
|
||||||
|
return redirect()->route('me.press-releases.show', ['id' => $pressRelease->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->token = (string) request()->query('t');
|
||||||
|
$this->source = $this->sourceFromRequest()->value;
|
||||||
|
$this->maskedEmail = NewsroomClaimService::mask($data['email']);
|
||||||
|
$this->title = (string) $pressRelease->title;
|
||||||
|
|
||||||
|
$company = $this->company($pressRelease);
|
||||||
|
|
||||||
|
if ($company !== null) {
|
||||||
|
$recorder->recordManagePageOpened(request(), $company, ConversionSource::parse($this->source), $pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestLink(MailAccessLink $links, ContactAccessService $contactAccess, ConversionRecorder $recorder): void
|
||||||
|
{
|
||||||
|
$data = $links->decode($this->token);
|
||||||
|
$pressRelease = $data === null ? null : $this->pressRelease($data['press_release_id']);
|
||||||
|
|
||||||
|
if ($pressRelease === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = 'mail-access|'.hash('sha256', $data['email']);
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||||
|
$this->addError('link', __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||||
|
'minutes' => max(1, (int) ceil(RateLimiter::availableIn($key) / 60)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($key, 3600);
|
||||||
|
|
||||||
|
$source = ConversionSource::parse($this->source);
|
||||||
|
$sent = $contactAccess->requestMagicAccess($data['email'], request()->ip(), $source, $pressRelease->company_id, $pressRelease->id);
|
||||||
|
$company = $this->company($pressRelease);
|
||||||
|
|
||||||
|
if ($sent && $company !== null) {
|
||||||
|
$recorder->record(ConversionEventType::AccessRequested, $source, $company, $pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceFromRequest(): ConversionSource
|
||||||
|
{
|
||||||
|
$source = ConversionSource::parse((string) request()->query('src'));
|
||||||
|
|
||||||
|
return in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true) ? $source : ConversionSource::Organic;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pressRelease(int $id): ?PressRelease
|
||||||
|
{
|
||||||
|
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function company(PressRelease $pressRelease): ?Company
|
||||||
|
{
|
||||||
|
return $pressRelease->company_id === null
|
||||||
|
? null
|
||||||
|
: Company::query()->withoutGlobalScope(PortalScope::class)->find($pressRelease->company_id);
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if ($sent)
|
||||||
|
<div class="mb-6 rounded-[2px] border border-bg-rule bg-bg-elev px-4 py-3 text-[13.5px] leading-[1.6] text-ink-2" role="status">
|
||||||
|
{{ __('Wir haben einen Anmeldelink an :email geschickt. Er ist 15 Minuten gültig; nach dem Klick landen Sie direkt bei Ihrer Pressemitteilung.', ['email' => $maskedEmail]) }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-4">
|
||||||
|
{{ __('Sie brauchen weder ein Konto noch ein Passwort. Wir schicken einen Anmeldelink an die Adresse, an die unsere Mail ging:') }}
|
||||||
|
</p>
|
||||||
|
<p class="mb-2 font-mono text-[14px] text-ink">{{ $maskedEmail }}</p>
|
||||||
|
<p class="mb-6 text-[12.5px] text-ink-3">{{ __('Pressemitteilung:') }} <span class="text-ink-2">{{ $title }}</span></p>
|
||||||
|
|
||||||
|
@error('link')
|
||||||
|
<p class="field-error mb-4">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<button type="button" class="auth-btn-primary" wire:click="requestLink" wire:loading.attr="disabled" wire:target="requestLink">
|
||||||
|
<span wire:loading.remove wire:target="requestLink">{{ __('Anmeldelink senden') }}</span>
|
||||||
|
<span wire:loading wire:target="requestLink">{{ __('Wird gesendet …') }}</span>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="mt-6 text-[12.5px] text-ink-3 leading-[1.6]">
|
||||||
|
{{ __('Nach der Anmeldung können Sie die Meldung ansehen und – je nach Stand – korrigieren, die Aufrufe verfolgen und den Newsroom Ihrer Firma pflegen. Das ist kostenlos.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
@ -27,6 +27,13 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
||||||
->middleware(['guest:'.config('fortify.guard')])
|
->middleware(['guest:'.config('fortify.guard')])
|
||||||
->name('magic-link.request');
|
->name('magic-link.request');
|
||||||
|
|
||||||
|
// Anmeldelink an die Adresse einer Kundenmail (Roadmap 6.17): nur über
|
||||||
|
// den signierten Link aus Veröffentlichungs- und Ablehnungsmail. Ohne
|
||||||
|
// `guest`: Ein angemeldeter Empfänger wird zur Meldung weitergeleitet.
|
||||||
|
Route::livewire('/zugang', 'auth.mail-access')
|
||||||
|
->middleware(['signed', 'throttle:20,1'])
|
||||||
|
->name('mail-access');
|
||||||
|
|
||||||
// Alte Pressekontakt-URL weiterleiten (Bestandslinks/Lesezeichen).
|
// Alte Pressekontakt-URL weiterleiten (Bestandslinks/Lesezeichen).
|
||||||
Route::redirect('/pressekontakt-zugang', '/anmeldelink');
|
Route::redirect('/pressekontakt-zugang', '/anmeldelink');
|
||||||
|
|
||||||
|
|
|
||||||
157
tests/Feature/Auth/MailAccessTest.php
Normal file
157
tests/Feature/Auth/MailAccessTest.php
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ConversionEventType;
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Mail\MagicLoginLink;
|
||||||
|
use App\Mail\PressReleasePublished;
|
||||||
|
use App\Mail\PressReleaseRejected;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\ConversionEvent;
|
||||||
|
use App\Models\MagicLink;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldelink an die Adresse einer Kundenmail (Roadmap 6.17): Empfänger von
|
||||||
|
* API-Meldungen haben aus ihrer Sicht kein Konto. Ein Klick in der Mail
|
||||||
|
* schickt den Anmeldelink an genau ihre Adresse, die Anmeldung landet auf
|
||||||
|
* der Meldung.
|
||||||
|
*/
|
||||||
|
beforeEach(function (): void {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
Mail::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
function apiRelease(array $attributes = []): PressRelease
|
||||||
|
{
|
||||||
|
$company = Company::factory()->businessportal24()->create([
|
||||||
|
'name' => 'Firma GmbH', 'slug' => 'firma-gmbh', 'email' => 'presse@firma.de', 'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(array_merge([
|
||||||
|
'company_id' => $company->id, 'language' => 'de', 'title' => 'Firma GmbH eröffnet Standort',
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{t: string, src: string}
|
||||||
|
*/
|
||||||
|
function accessQuery(string $url): array
|
||||||
|
{
|
||||||
|
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return ['t' => (string) $query['t'], 'src' => (string) $query['src']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Mails
|
||||||
|
|
||||||
|
test('the publication mail offers a sign-in link to the address it went to when the recipient has no account', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$release = apiRelease();
|
||||||
|
$mail = new PressReleasePublished($release->user, $release, 'Frau Muster', 'presse@firma.de', true);
|
||||||
|
$html = $mail->render();
|
||||||
|
|
||||||
|
expect($mail->accessEmail)->toBe('presse@firma.de')
|
||||||
|
->and($mail->accessUrl)->toContain('/zugang?')
|
||||||
|
->and($mail->accessUrl)->not->toContain('presse@firma.de')
|
||||||
|
->and($mail->accessUrl)->not->toContain('presse%40firma.de')
|
||||||
|
->and($html)->toContain('Etwas ändern oder die Aufrufe sehen?')
|
||||||
|
->and($html)->toContain('weder ein Konto noch ein Passwort')
|
||||||
|
->and($html)->toContain('presse@firma.de')
|
||||||
|
->and($html)->toContain('den Newsroom von Firma GmbH pflegen')
|
||||||
|
->and($html)->toContain('Anmeldelink anfordern')
|
||||||
|
->and($html)->not->toContain('Im pressekonto öffnen')
|
||||||
|
->and($html)->not->toContain('mit Ihrem Passwort oder per Magic-Link');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the rejection mail lets recipients without an account view the release, resubmission stays with the original channel', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$release = apiRelease(['status' => 'rejected']);
|
||||||
|
$html = (new PressReleaseRejected($release->user, $release, 'Kein Anlass erkennbar.', 'Frau Muster', 'presse@firma.de'))->render();
|
||||||
|
|
||||||
|
expect($html)->toContain('ohne Konto und ohne Passwort ansehen')
|
||||||
|
->and($html)->toContain('auf demselben Weg ein wie diese Meldung')
|
||||||
|
->and($html)->toContain('Anmeldelink anfordern')
|
||||||
|
->and($html)->not->toContain('Pressemitteilung bearbeiten');
|
||||||
|
|
||||||
|
// Wer selbst eingereicht hat, überarbeitet wie bisher im Konto.
|
||||||
|
$own = (new PressReleaseRejected($release->user, $release, 'Kein Anlass erkennbar.'))->render();
|
||||||
|
expect($own)->toContain('Pressemitteilung bearbeiten')
|
||||||
|
->and($own)->not->toContain('Anmeldelink anfordern');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------- Zugangsseite
|
||||||
|
|
||||||
|
test('the access page only opens with a valid signature and shows the address masked', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$release = apiRelease();
|
||||||
|
$url = app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish);
|
||||||
|
|
||||||
|
$this->get($url)
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeText('p***@firma.de')
|
||||||
|
->assertSeeText('Firma GmbH eröffnet Standort')
|
||||||
|
->assertDontSeeText('presse@firma.de');
|
||||||
|
|
||||||
|
$this->get(str_replace('src=mail_publish', 'src=mail_reject', $url))->assertForbidden();
|
||||||
|
|
||||||
|
$opened = ConversionEvent::query()->where('type', ConversionEventType::ManagePageOpened->value)->sole();
|
||||||
|
expect($opened->source)->toBe(ConversionSource::MailPublish)
|
||||||
|
->and($opened->press_release_id)->toBe($release->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one click sends the sign-in link to that address and the login lands on the release', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$release = apiRelease();
|
||||||
|
$url = app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish);
|
||||||
|
|
||||||
|
Livewire::withQueryParams(accessQuery($url))
|
||||||
|
->test('auth.mail-access')
|
||||||
|
->call('requestLink')
|
||||||
|
->assertSet('sent', true)
|
||||||
|
->assertSee('p***@firma.de');
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'presse@firma.de')->firstOrFail();
|
||||||
|
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail): bool => $mail->user->is($user));
|
||||||
|
|
||||||
|
$link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail();
|
||||||
|
expect($link->source)->toBe(ConversionSource::MailPublish->value)
|
||||||
|
->and($link->payload)->toBe(['company_id' => $release->company_id, 'press_release_id' => $release->id])
|
||||||
|
->and(ConversionEvent::query()->where('type', ConversionEventType::AccessRequested->value)->sole()->company_id)->toBe($release->company_id);
|
||||||
|
|
||||||
|
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
|
||||||
|
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
|
||||||
|
->assertRedirect(route('me.press-releases.show', ['id' => $release->id], absolute: false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests per address are limited', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$release = apiRelease();
|
||||||
|
$query = accessQuery(app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailReject));
|
||||||
|
|
||||||
|
foreach (range(1, 3) as $i) {
|
||||||
|
Livewire::withQueryParams($query)->test('auth.mail-access')->call('requestLink')->assertHasNoErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
Livewire::withQueryParams($query)->test('auth.mail-access')->call('requestLink')->assertHasErrors('link');
|
||||||
|
Mail::assertQueuedCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a signed-in recipient goes straight to the release', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$release = apiRelease();
|
||||||
|
$user = User::factory()->create(['email' => 'presse@firma.de', 'is_active' => true]);
|
||||||
|
$user->assignRole('customer');
|
||||||
|
$user->companies()->attach($release->company_id, ['role' => 'responsible']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish))
|
||||||
|
->assertRedirect(route('me.press-releases.show', ['id' => $release->id]));
|
||||||
|
});
|
||||||
|
|
@ -101,7 +101,7 @@ test('the published mail links both portals for a cross-portal press release', f
|
||||||
->toContain(config('domains.domain_businessportal_url').'/de/press-release/'.$pressRelease->slug);
|
->toContain(config('domains.domain_businessportal_url').'/de/press-release/'.$pressRelease->slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the published mail links the account area below the portal links with a login hint', function () {
|
test('the published mail links the account area below the portal links for customers with an account', function () {
|
||||||
$pressRelease = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create();
|
$pressRelease = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create();
|
||||||
|
|
||||||
$mailable = new PressReleasePublished($pressRelease->user, $pressRelease);
|
$mailable = new PressReleasePublished($pressRelease->user, $pressRelease);
|
||||||
|
|
@ -114,12 +114,11 @@ test('the published mail links the account area below the portal links with a lo
|
||||||
// zwei Parameter (`src`, `pr`), und Blade escapt das trennende `&` im
|
// zwei Parameter (`src`, `pr`), und Blade escapt das trennende `&` im
|
||||||
// HTML korrekt zu `&`.
|
// HTML korrekt zu `&`.
|
||||||
->toContain(e($mailable->showUrl))
|
->toContain(e($mailable->showUrl))
|
||||||
->toContain('Pressemitteilung im pressekonto öffnen')
|
->toContain('Im pressekonto öffnen')
|
||||||
->toContain('Login-Bereich')
|
->not->toContain('Anmeldelink anfordern');
|
||||||
->toContain('mit Ihrem Passwort oder per Magic-Link');
|
|
||||||
|
|
||||||
// Reihenfolge: erst der öffentliche Portal-Link, darunter der Konto-Link.
|
// Reihenfolge: erst der öffentliche Portal-Link, darunter der Konto-Link.
|
||||||
expect(mb_strpos($html, $publicUrl))->toBeLessThan(mb_strpos($html, 'Pressemitteilung im pressekonto öffnen'));
|
expect(mb_strpos($html, $publicUrl))->toBeLessThan(mb_strpos($html, 'Im pressekonto öffnen'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the termination mails use the hub ci layout', function () {
|
test('the termination mails use the hub ci layout', function () {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue