Umbenennung presseportale → pressekonto in Domains, Themes und Dokumentation. Design-Tokens, Portal-Shell, Customer-Dashboard, Auth- und Admin-PM-Views. Artisan-Befehl migrate:legacy-media mit Tests und Hub-Flux-Entwicklungsdocs. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
7.6 KiB
PHP
211 lines
7.6 KiB
PHP
<?php
|
|
|
|
use App\Mail\MagicLoginLink;
|
|
use App\Models\User;
|
|
use App\Services\Auth\MagicLinkGenerator;
|
|
use Illuminate\Auth\Events\Lockout;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Facades\Route;
|
|
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();
|
|
|
|
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
|
|
RateLimiter::hit($this->throttleKey());
|
|
|
|
throw ValidationException::withMessages([
|
|
'email' => __('auth.failed'),
|
|
]);
|
|
}
|
|
|
|
$authenticatedUser = Auth::user();
|
|
if ($authenticatedUser) {
|
|
$authenticatedUser->update([
|
|
'last_login_at' => now(),
|
|
'last_login_ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
RateLimiter::clear($this->throttleKey());
|
|
Session::regenerate();
|
|
|
|
// Rollen-basierter Default-Redirect:
|
|
// Admin/Editor → /dashboard, Customer → /admin/me.
|
|
// Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt
|
|
// (build/portal mit FluxUI) als das Hub-Auth-Layout (build/web).
|
|
// SPA-Navigation kann den Bundle-Wechsel nicht handhaben.
|
|
$defaultRoute = $authenticatedUser?->canAccessAdmin()
|
|
? route('dashboard', absolute: false)
|
|
: ($authenticatedUser?->canAccessCustomer()
|
|
? route('me.dashboard', absolute: false)
|
|
: '/');
|
|
|
|
$this->redirectIntended(default: $defaultRoute);
|
|
}
|
|
|
|
public function sendMagicLink(): void
|
|
{
|
|
$this->validateOnly('email');
|
|
|
|
$user = User::query()->where('email', $this->email)->first();
|
|
|
|
if ($user && $user->is_active) {
|
|
$generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip());
|
|
$loginUrl = route('magic-links.consume', ['token' => $generated['plain_token']]);
|
|
|
|
Mail::to($user->email)->send(
|
|
new MagicLoginLink(
|
|
user: $user,
|
|
loginUrl: $loginUrl,
|
|
expiresAt: $generated['expires_at']->format('d.m.Y H:i')
|
|
)
|
|
);
|
|
}
|
|
|
|
session()->flash('status', __('If an active account exists for this email, we sent a magic login link.'));
|
|
}
|
|
|
|
/**
|
|
* 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>
|
|
|
|
<button
|
|
type="button"
|
|
wire:click="sendMagicLink"
|
|
wire:loading.attr="disabled"
|
|
wire:target="sendMagicLink"
|
|
class="auth-btn-outline !mt-0"
|
|
>
|
|
<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 wire:loading.remove wire:target="sendMagicLink">Magic-Link senden</span>
|
|
<span wire:loading wire:target="sendMagicLink">Magic-Link wird gesendet …</span>
|
|
</button>
|
|
</form>
|
|
</div>
|