presseportale/resources/views/livewire/auth/magic-link.blade.php
Kevin Adametz 6c6b9e0f26 WS-2: Magic-Link für Firmen & Pressekontakte vereinheitlicht + Schreibzugriff
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>
2026-06-16 12:55:49 +00:00

136 lines
4.9 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' => '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>