presseportale/resources/views/livewire/auth/contact-access.blade.php
Kevin Adametz 980763c362 WS-2: Firmen-Scope für PMs & Magic-Link-Zugang für Pressekontakte
Firmen-Scope (Fundament):
- PM-Zugriff war hart an user_id (Autor) gebunden. Jetzt additiv: Autor ODER
  Mitglied der zugeordneten Firma (Owner via owner_user_id oder company_user-
  Pivot). Geändert in PressReleasePolicy (canManage) sowie den Queries der
  Listen-, Show- und Edit-Komponenten. Helfer User::accessibleCompanyIds()/
  canAccessCompany(). Solo-Owner unverändert; Firmenmitglieder sehen/bearbeiten
  alle PMs ihrer Firma.

Magic-Link-Zugang für Pressekontakte (ContactAccessService):
- Öffentliches, enumeration-sicheres Formular (/pressekontakt-zugang) mit
  Honeypot + Rate-Limit. Eine hinterlegte Kontakt-E-Mail führt zu einem lazy
  angelegten, de-duplizierten customer-Account (aktiv, verifiziert über den
  Magic-Link-Kanal), der den Firmen seiner Kontakte als Mitglied zugeordnet
  wird. Versand über den bestehenden Login-Magic-Link (Generator + Consume
  wiederverwendet) – keine Schema-Änderung, kein paralleles System.
- Dezenter Einstiegslink von der Login-Seite (PM-Frontend-Wiring später).

Tests: PressReleaseCompanyScopeTest (3), ContactAccessTest (6, inkl. De-Dup,
Enumeration-Sicherheit, Honeypot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 08:33:12 +00:00

105 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Services\Auth\ContactAccessService;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Pressemitteilung verwalten', 'eyebrow' => 'Zugang für Pressekontakte', 'topRightLabel' => 'Konto vorhanden?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
#[Validate('required|string|email')]
public string $email = '';
// Honeypot gegen einfache Bots muss leer bleiben.
public string $website = '';
public function requestAccess(ContactAccessService $contactAccess): void
{
$this->validate();
if ($this->website !== '') {
// Bot: identische neutrale Antwort, keine Aktion.
$this->sent();
return;
}
$this->ensureIsNotRateLimited();
RateLimiter::hit($this->throttleKey(), 600);
$contactAccess->requestAccess($this->email, request()->ip());
$this->sent();
}
private function sent(): void
{
$this->reset('email');
session()->flash('status', __('Falls für diese E-Mail-Adresse ein Pressekontakt hinterlegt ist, haben wir Ihnen einen Zugangslink geschickt.'));
}
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', ['minutes' => ceil($seconds / 60)]),
]);
}
protected function throttleKey(): string
{
return 'contact-access|'.Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
}; ?>
<div>
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
</div>
@endif
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
Sind Sie als Pressekontakt einer Firma hinterlegt? Geben Sie Ihre
E-Mail-Adresse ein wir senden Ihnen einen Link, mit dem Sie die
Pressemitteilungen Ihrer Firma verwalten können.
</p>
<form wire:submit="requestAccess" class="space-y-[18px]" novalidate>
<div>
<label class="field-label" for="contact-email">E-Mail-Adresse</label>
<input
id="contact-email"
type="email"
wire:model="email"
required
autofocus
autocomplete="email"
class="field-input"
placeholder="kontakt@ihre-firma.de"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
{{-- Honeypot: für Menschen unsichtbar --}}
<div class="hidden" aria-hidden="true">
<label for="contact-website">Website</label>
<input id="contact-website" type="text" wire:model="website" tabindex="-1" autocomplete="off" />
</div>
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="requestAccess">
<span wire:loading.remove wire:target="requestAccess">Zugangslink anfordern</span>
<span wire:loading wire:target="requestAccess">Wird gesendet …</span>
</button>
</form>
</div>