presseportale/resources/views/livewire/auth/login.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

221 lines
8.2 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\Models\User;
use App\Support\LoginRedirect;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
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' => 'Willkommen zurück', 'eyebrow' => 'Anmeldung im Publisher-Hub', 'topRightLabel' => 'Noch kein Konto?', 'topRightLinkText' => 'Konto erstellen', 'topRightLinkHref' => '/register'])] class extends Component {
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = false;
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->ensureIsNotRateLimited();
// Zugangsdaten prüfen, OHNE schon einzuloggen sonst würde die
// Fortify-2FA-Pipeline umgangen (2FA-Bypass). Legacy-User ohne Passwort
// (password = null) scheitern hier korrekt und nutzen Magic-Link/Reset.
$user = User::query()->where('email', $this->email)->first();
if (! $user || ! $user->password || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
// 2FA aktiv: nicht einloggen, sondern in Fortifys Challenge übergeben
// (Session-Vertrag login.id/login.remember wie RedirectIfTwoFactorAuthenticatable).
if ($user->hasEnabledTwoFactorAuthentication()) {
Session::put([
'login.id' => $user->getKey(),
'login.remember' => $this->remember,
]);
$this->redirect(route('two-factor.challenge'));
return;
}
Auth::login($user, $this->remember);
// Unverifizierte Selbst-Registrierer → Notice-Seite.
if (! $user->hasVerifiedEmail()) {
Session::regenerate();
$this->redirect(route('verification.notice', absolute: false));
return;
}
// Verifiziert, aber deaktiviert: Login zentral blockieren.
if (! $user->is_active) {
Auth::logout();
throw ValidationException::withMessages([
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
]);
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => request()->ip(),
]);
Session::regenerate();
// Rollengerechter, 403-sicherer Redirect. Ohne navigate:true, weil das
// Portal ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
$this->redirect(LoginRedirect::safeTarget(
$user,
Session::pull('url.intended'),
LoginRedirect::homeFor($user),
));
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
}; ?>
<div>
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
</div>
@endif
<form wire:submit="login" class="space-y-[18px]" x-data="{ showPassword: false }" novalidate>
<div>
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
<input
id="auth-email"
type="email"
wire:model="email"
autocomplete="username"
required
autofocus
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<div class="flex items-baseline justify-between mb-1.5">
<label class="field-label !mb-0" for="auth-password">Passwort</label>
@if (\Illuminate\Support\Facades\Route::has('password.request'))
<a href="{{ route('password.request') }}" class="link-hub text-[12px]" wire:navigate>
Passwort vergessen?
</a>
@endif
</div>
<div class="field-pw-wrap">
<input
id="auth-password"
wire:model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
class="field-input pr-[72px]"
placeholder="••••••••••"
@error('password') aria-invalid="true" @enderror
/>
<button
type="button"
class="field-affix"
@click="showPassword = !showPassword"
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<label class="flex items-center gap-2.5 text-[12.5px] text-ink-2 cursor-pointer select-none">
<input type="checkbox" wire:model="remember" class="auth-check" />
Angemeldet bleiben
</label>
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="login">
<span wire:loading.remove wire:target="login">Anmelden</span>
<span wire:loading wire:target="login">Anmelden …</span>
</button>
<div class="flex items-center gap-3 !mt-[22px] !mb-[14px]">
<span class="flex-1 h-px bg-bg-rule"></span>
<span class="text-[11px] font-semibold tracking-[0.18em] uppercase text-ink-3">oder</span>
<span class="flex-1 h-px bg-bg-rule"></span>
</div>
<a href="{{ route('magic-link.request') }}" class="auth-btn-outline !mt-0" wire:navigate>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="3" width="12" height="10" stroke="currentColor" stroke-width="1.4" />
<path d="M2.5 4l5.5 5 5.5-5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
</svg>
<span>Anmeldung per E-Mail-Link</span>
</a>
<a href="{{ route('oauth.google.redirect') }}" class="auth-btn-outline !mt-3">
<svg width="15" height="15" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.01-2.33z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
</svg>
<span>Mit Google anmelden</span>
</a>
</form>
</div>