Magic-Link und Pressekontakt-Zugang zu einer Seite (/anmeldelink) zusammengeführt; altes Login-Modal entfernt, /pressekontakt-zugang leitet weiter. - ContactAccessService deckt jetzt Firmen-E-Mail UND Pressekontakt-E-Mail ab, portalübergreifend (ohne PortalScope). Eine E-Mail mehrfach hinterlegt → genau ein Account, dem alle Firmen + Kontakte zugeordnet werden. - Zugeordnete Firmen erhalten Pivot-Rolle 'responsible' (Schreibzugriff auf Stammdaten, Kontakte, Pressemitteilungen) statt nur 'member'; bestehende Lese-Pivots werden hochgestuft, Owner bleiben unangetastet. - Neuer Login-Listener (SyncCompanyMembershipsOnLogin) frischt die Zuordnungen bei JEDEM Login (Magic-Link, Passwort, Google) auf – auch nachträglich (API) hinzugekommene Firmen/Kontakte mit gleicher E-Mail greifen. - Auth-Bereich erzwingt Hellmodus: aus dem Portal übernommene .dark-Klasse wird am <html> entfernt (Login war im Dark Mode hängengeblieben). - Tests: Firmen-E-Mail-Login, Multi-Firmen-Aggregation, Schreibzugriff/Upgrade, Per-Login-Re-Sync, Auth-Hellmodus. Sicherheits-Doku aktualisiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
4.9 KiB
PHP
136 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>
|
||
@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">
|
||
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>
|