Self-conditional Component fuer den session('status')-Block (field-status +
role=status); Margin per class. Ersetzt den identischen 5-Zeilen-Block in
login, forgot-password, register, magic-link.
Bewusst NICHT umgestellt: verify-email (fixe Meldung statt session('status'),
Bedingung ===verification-link-sent, mb-6) und settings/profile (eigener
Kontext) — bleiben unveraendert. Alle Views kompilieren, 95 Auth-Tests gruen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
4.9 KiB
PHP
129 lines
4.9 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' => 'Anmeldung per E-Mail-Link', 'eyebrow' => 'Ohne Passwort anmelden', 'topRightLabel' => 'Lieber mit Passwort?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
||
#[Validate('required|string|email')]
|
||
public string $email = '';
|
||
|
||
// Honeypot gegen einfache Bots – muss leer bleiben.
|
||
public string $website = '';
|
||
|
||
/**
|
||
* Versendet einen Anmeldelink. Deckt Bestandskonten UND hinterlegte
|
||
* Pressekontakte (lazy Account) über denselben Service-Eintritt ab.
|
||
* Enumeration-sicher: identische neutrale Antwort in jedem Fall.
|
||
*/
|
||
public function requestLink(ContactAccessService $contactAccess): void
|
||
{
|
||
$this->validate();
|
||
|
||
if ($this->website !== '') {
|
||
// Bot: identische neutrale Antwort, keine Aktion.
|
||
$this->sent();
|
||
|
||
return;
|
||
}
|
||
|
||
$this->ensureIsNotRateLimited();
|
||
RateLimiter::hit($this->throttleKey(), 3600);
|
||
RateLimiter::hit($this->ipThrottleKey(), 3600);
|
||
|
||
$contactAccess->requestMagicAccess($this->email, request()->ip());
|
||
|
||
$this->sent();
|
||
}
|
||
|
||
private function sent(): void
|
||
{
|
||
$this->reset('email');
|
||
session()->flash('status', __('Falls für diese E-Mail-Adresse ein aktives Konto oder ein hinterlegter Pressekontakt existiert, haben wir Ihnen einen Anmeldelink geschickt.'));
|
||
}
|
||
|
||
/**
|
||
* Versand drosseln: pro E-Mail+IP (gegen Mail-Fluten eines Accounts und das
|
||
* laufende Entwerten alter Links) und zusätzlich pro IP (gegen das
|
||
* Durchprobieren vieler Accounts von einer Quelle).
|
||
*/
|
||
protected function ensureIsNotRateLimited(): void
|
||
{
|
||
$withinEmail = ! RateLimiter::tooManyAttempts($this->throttleKey(), 3);
|
||
$withinIp = ! RateLimiter::tooManyAttempts($this->ipThrottleKey(), 15);
|
||
|
||
if ($withinEmail && $withinIp) {
|
||
return;
|
||
}
|
||
|
||
$seconds = max(
|
||
RateLimiter::availableIn($this->throttleKey()),
|
||
RateLimiter::availableIn($this->ipThrottleKey()),
|
||
);
|
||
|
||
throw ValidationException::withMessages([
|
||
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||
'minutes' => max(1, ceil($seconds / 60)),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
protected function throttleKey(): string
|
||
{
|
||
return 'magic-link|'.Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||
}
|
||
|
||
protected function ipThrottleKey(): string
|
||
{
|
||
return 'magic-link-ip|'.request()->ip();
|
||
}
|
||
}; ?>
|
||
|
||
<div>
|
||
<x-session-status class="mb-4" />
|
||
|
||
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
|
||
{{ __('Geben Sie Ihre E-Mail-Adresse ein – wir senden Ihnen einen Anmeldelink, mit dem Sie sich ohne Passwort anmelden können. Das funktioniert für bestehende Konten ebenso wie für jede E-Mail, die bei einer') }}
|
||
<strong class="text-ink-2 font-semibold">{{ __('Firma') }}</strong> {{ __('oder als') }}
|
||
<strong class="text-ink-2 font-semibold">{{ __('Pressekontakt') }}</strong> {{ __('hinterlegt ist – Sie verwalten damit die Pressemitteilungen aller zugeordneten Firmen.') }}
|
||
</p>
|
||
|
||
<form wire:submit="requestLink" class="space-y-[18px]" novalidate>
|
||
<div>
|
||
<label class="field-label" for="magic-email">{{ __('E-Mail-Adresse') }}</label>
|
||
<input
|
||
id="magic-email"
|
||
type="email"
|
||
wire:model="email"
|
||
required
|
||
autofocus
|
||
autocomplete="email"
|
||
class="field-input"
|
||
placeholder="redaktion@ihr-unternehmen.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="magic-website">{{ __('Website') }}</label>
|
||
<input id="magic-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="requestLink">
|
||
<span wire:loading.remove wire:target="requestLink">{{ __('Anmeldelink senden') }}</span>
|
||
<span wire:loading wire:target="requestLink">{{ __('Wird gesendet …') }}</span>
|
||
</button>
|
||
|
||
<a href="{{ route('login') }}" class="auth-btn-outline !mt-3" wire:navigate>
|
||
{{ __('Zurück zur Anmeldung') }}
|
||
</a>
|
||
</form>
|
||
</div>
|