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>
105 lines
3.6 KiB
PHP
105 lines
3.6 KiB
PHP
<?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>
|