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>
This commit is contained in:
parent
068a5a4b49
commit
6c6b9e0f26
12 changed files with 587 additions and 327 deletions
30
app/Listeners/SyncCompanyMembershipsOnLogin.php
Normal file
30
app/Listeners/SyncCompanyMembershipsOnLogin.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
|
||||
/**
|
||||
* Synchronisiert bei JEDEM Login (Magic-Link, Passwort, Google) die Firmen- und
|
||||
* Kontakt-Zuordnungen des Users anhand seiner E-Mail. So greifen auch
|
||||
* nachträglich (z. B. per API) angelegte Firmen/Kontakte mit derselben E-Mail,
|
||||
* unabhängig vom Anmeldeweg. Rein additiv – legt kein Konto an, verschickt
|
||||
* keine Mail (siehe ContactAccessService::syncMembershipsForEmail()).
|
||||
*/
|
||||
class SyncCompanyMembershipsOnLogin
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactAccessService $contactAccess,
|
||||
) {}
|
||||
|
||||
public function handle(Login $event): void
|
||||
{
|
||||
if (! $event->user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->contactAccess->syncMembershipsForEmail($event->user);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use App\Helpers\ThemeHelper;
|
|||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
use App\Listeners\ActivateUserAfterVerification;
|
||||
use App\Listeners\SyncCompanyMembershipsOnLogin;
|
||||
use App\Models\AdminPreset;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryTranslation;
|
||||
|
|
@ -19,6 +20,7 @@ use App\Observers\AdminPerformanceCacheObserver;
|
|||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||
use App\Services\Newsletter\NullNewsletterSyncClient;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
|
|
@ -61,6 +63,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
Event::listen(Registered::class, SendEmailVerificationNotification::class);
|
||||
Event::listen(Verified::class, ActivateUserAfterVerification::class);
|
||||
|
||||
// Bei jedem Login Firmen-/Kontakt-Zuordnungen (gleiche E-Mail) auffrischen.
|
||||
Event::listen(Login::class, SyncCompanyMembershipsOnLogin::class);
|
||||
|
||||
// Stripe Tax berechnet die USt im Checkout automatisch nach den
|
||||
// gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer,
|
||||
// EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich
|
||||
|
|
|
|||
|
|
@ -4,20 +4,24 @@ namespace App\Services\Auth;
|
|||
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Magic-Link-Zugang für Pressekontakte (WS-2).
|
||||
* Magic-Link-Zugang für Firmen & Pressekontakte (WS-2).
|
||||
*
|
||||
* Hat eine im System hinterlegte Kontakt-E-Mail Zugang angefragt, wird – lazy –
|
||||
* ein Account angelegt (oder ein bestehender per E-Mail wiederverwendet), dem
|
||||
* Kontakt-Firmen als Mitglied zugeordnet und ein regulärer Login-Magic-Link
|
||||
* verschickt. Über den Firmen-Scope (PressReleasePolicy) verwaltet der Kontakt
|
||||
* danach die PMs seiner Firma(en). Eintrittsweg in dieselbe Verwaltung wie der
|
||||
* reguläre Login – kein paralleles System.
|
||||
* Überall, wo eine E-Mail hinterlegt ist – an einer Firma (`companies.email`)
|
||||
* oder an einem Pressekontakt (`contacts.email`) –, kann sich diese E-Mail per
|
||||
* Magic-Link anmelden. Es entsteht (lazy) GENAU EIN Account je E-Mail, dem ALLE
|
||||
* Firmen und Pressekontakte zugeordnet werden, die diese E-Mail tragen – über
|
||||
* alle Portale hinweg. Über den Firmen-Scope (PressReleasePolicy) verwaltet der
|
||||
* Account danach die Pressemitteilungen (inkl. Terminierung/Kalender) und
|
||||
* Kontakte seiner Firmen. Eintrittsweg in dieselbe Verwaltung wie der reguläre
|
||||
* Login – kein paralleles System.
|
||||
*/
|
||||
class ContactAccessService
|
||||
{
|
||||
|
|
@ -27,24 +31,56 @@ class ContactAccessService
|
|||
) {}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine Zugangsanfrage. Enumeration-sicher: Der Aufrufer zeigt
|
||||
* unabhängig vom Ergebnis dieselbe neutrale Meldung; gibt zurück, ob ein
|
||||
* Link verschickt wurde (nur für interne Tests/Logs).
|
||||
* Einheitlicher „Anmeldung per E-Mail-Link"-Eintritt für die zusammengeführte
|
||||
* Seite: deckt bestehende (aktive) Konten, hinterlegte Firmen-E-Mails UND
|
||||
* Pressekontakte ab. Enumeration-sicher – der Aufrufer zeigt unabhängig vom
|
||||
* Ergebnis dieselbe neutrale Meldung.
|
||||
*
|
||||
* - Bestehendes aktives Konto → Login-Magic-Link (auch ohne Hinterlegung).
|
||||
* - Hinterlegte Firma/Kontakt(e) ohne Konto → lazy Account + Scope + Link.
|
||||
* - Beides zugleich → Konto wiederverwenden, Scope ergänzen, Link.
|
||||
* - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet.
|
||||
* - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion.
|
||||
*/
|
||||
public function requestMagicAccess(string $email, ?string $ip = null): bool
|
||||
{
|
||||
$email = mb_strtolower(trim($email));
|
||||
$contacts = $this->contactsFor($email);
|
||||
$companies = $this->companiesFor($email);
|
||||
$existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
|
||||
if (! $existing && $contacts->isEmpty() && $companies->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $existing ?? $this->createAccount($email, $contacts, $companies);
|
||||
|
||||
// Deaktivierte Bestands-Accounts nicht verändern und nicht einloggen.
|
||||
if (! $user->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
|
||||
$this->sendLoginLink($user, $ip);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine reine Pressekontakt-Zugangsanfrage (nur hinterlegte
|
||||
* Kontakte). Enumeration-sicher; gibt zurück, ob ein Link verschickt wurde
|
||||
* (Tests/Logs). Bleibt für gezielte Aufrufe erhalten.
|
||||
*/
|
||||
public function requestAccess(string $email, ?string $ip = null): bool
|
||||
{
|
||||
$email = mb_strtolower(trim($email));
|
||||
|
||||
$contacts = Contact::query()
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
->whereNotNull('company_id')
|
||||
->get();
|
||||
$contacts = $this->contactsFor($email);
|
||||
|
||||
if ($contacts->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->resolveUser($email, $contacts);
|
||||
$user = $this->resolveUser($email, $contacts, collect());
|
||||
|
||||
// Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte
|
||||
// Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link
|
||||
|
|
@ -53,37 +89,111 @@ class ContactAccessService
|
|||
return false;
|
||||
}
|
||||
|
||||
$this->linkCompanies($user, $contacts);
|
||||
|
||||
$generated = $this->magicLinks->createLoginLink($user, $ip);
|
||||
|
||||
Mail::to($user->email)->send(new MagicLoginLink(
|
||||
user: $user,
|
||||
loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]),
|
||||
expiresAt: $generated['expires_at']->format('d.m.Y H:i'),
|
||||
));
|
||||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, collect()), $contacts);
|
||||
$this->sendLoginLink($user, $ip);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestehenden Account per E-Mail wiederverwenden (keine Dubletten) oder lazy
|
||||
* einen neuen anlegen. Verifizierung gilt über den Magic-Link-Kanal als
|
||||
* erfüllt (Entscheidung 15.06.) – daher direkt aktiv + customer.
|
||||
*
|
||||
* @param Collection<int, Contact> $contacts
|
||||
* Synchronisiert die Firmen-/Kontakt-Zuordnungen eines bereits
|
||||
* angemeldeten Users anhand seiner E-Mail. Wird bei JEDEM Login aufgerufen
|
||||
* (Magic-Link, Passwort, Google) – so greifen auch nachträglich (z. B. per
|
||||
* API) hinzugekommene Firmen/Kontakte mit derselben E-Mail. Rein additiv,
|
||||
* legt kein Konto an und verschickt keine Mail.
|
||||
*/
|
||||
private function resolveUser(string $email, Collection $contacts): User
|
||||
public function syncMembershipsForEmail(User $user): void
|
||||
{
|
||||
$existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
$email = mb_strtolower(trim((string) $user->email));
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
if ($email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$contacts = $this->contactsFor($email);
|
||||
$companies = $this->companiesFor($email);
|
||||
|
||||
if ($contacts->isEmpty() && $companies->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive,
|
||||
* portalübergreifend (kein PortalScope).
|
||||
*
|
||||
* @return Collection<int, Contact>
|
||||
*/
|
||||
private function contactsFor(string $email): Collection
|
||||
{
|
||||
return Contact::withoutGlobalScope(PortalScope::class)
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
->whereNotNull('company_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Firmen, die diese E-Mail selbst hinterlegt haben – case-insensitive,
|
||||
* portalübergreifend (kein PortalScope).
|
||||
*
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
private function companiesFor(string $email): Collection
|
||||
{
|
||||
return Company::withoutGlobalScope(PortalScope::class)
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle zuzuordnenden Firmen-IDs: direkt an Firmen hinterlegt ODER über
|
||||
* Pressekontakte verknüpft.
|
||||
*
|
||||
* @param Collection<int, Contact> $contacts
|
||||
* @param Collection<int, Company> $companies
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function companyIdsFrom(Collection $contacts, Collection $companies): Collection
|
||||
{
|
||||
return $companies->pluck('id')
|
||||
->merge($contacts->pluck('company_id'))
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestehenden Account per E-Mail wiederverwenden (keine Dubletten) oder lazy
|
||||
* einen neuen anlegen.
|
||||
*
|
||||
* @param Collection<int, Contact> $contacts
|
||||
* @param Collection<int, Company> $companies
|
||||
*/
|
||||
private function resolveUser(string $email, Collection $contacts, Collection $companies): User
|
||||
{
|
||||
return User::query()->whereRaw('LOWER(email) = ?', [$email])->first()
|
||||
?? $this->createAccount($email, $contacts, $companies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt für eine hinterlegte Firma/Kontakt lazy einen Account an. Verifizierung
|
||||
* gilt über den Magic-Link-Kanal als erfüllt (Entscheidung 15.06.) – daher
|
||||
* direkt aktiv + customer. Name: Kontaktname, sonst Firmenname, sonst E-Mail.
|
||||
*
|
||||
* @param Collection<int, Contact> $contacts
|
||||
* @param Collection<int, Company> $companies
|
||||
*/
|
||||
private function createAccount(string $email, Collection $contacts, Collection $companies): User
|
||||
{
|
||||
$contact = $contacts->first();
|
||||
$name = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
$name = (string) ($companies->first()->name ?? '');
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
'email' => $email,
|
||||
|
|
@ -98,24 +208,53 @@ class ContactAccessService
|
|||
}
|
||||
|
||||
/**
|
||||
* Ordnet den User den Firmen seiner Kontakte als Mitglied zu (Firmen-Scope)
|
||||
* und verknüpft ihn mit den Kontakt-Datensätzen. Owner bleiben Owner.
|
||||
* Versendet einen regulären Login-Magic-Link an den Account.
|
||||
*/
|
||||
private function sendLoginLink(User $user, ?string $ip): void
|
||||
{
|
||||
$generated = $this->magicLinks->createLoginLink($user, $ip);
|
||||
|
||||
Mail::to($user->email)->send(new MagicLoginLink(
|
||||
user: $user,
|
||||
loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]),
|
||||
expiresAt: $generated['expires_at']->format('d.m.Y H:i'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet den User allen übergebenen Firmen als „Verantwortlicher"
|
||||
* (`responsible`) zu und verknüpft ihn mit den Kontakt-Datensätzen. Wer seine
|
||||
* E-Mail an einer Firma/einem Pressekontakt hinterlegt hat, verwaltet die
|
||||
* gesamte Firma (Stammdaten, Kontakte, Pressemitteilungen) – `responsible`
|
||||
* gewährt den dafür nötigen Schreibzugriff (siehe CompanyPolicy::update()).
|
||||
*
|
||||
* Owner (`owner_user_id`) bleiben unangetastet; ein bestehender reiner
|
||||
* Lese-Pivot (`member`) wird auf `responsible` hochgestuft, `owner`/
|
||||
* `responsible` bleiben erhalten.
|
||||
*
|
||||
* @param Collection<int, int> $companyIds
|
||||
* @param Collection<int, Contact> $contacts
|
||||
*/
|
||||
private function linkCompanies(User $user, Collection $contacts): void
|
||||
private function linkMemberships(User $user, Collection $companyIds, Collection $contacts): void
|
||||
{
|
||||
$companyIds = $contacts->pluck('company_id')->filter()->unique();
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
$alreadyLinked = $user->ownedCompanies()->whereKey($companyId)->exists()
|
||||
|| $user->companies()->whereKey($companyId)->exists();
|
||||
// Owner via owner_user_id → bereits voller Zugriff.
|
||||
if ($user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $alreadyLinked) {
|
||||
$user->companies()->attach($companyId, ['role' => 'member']);
|
||||
$linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first();
|
||||
|
||||
if ($linked === null) {
|
||||
$user->companies()->attach($companyId, ['role' => 'responsible']);
|
||||
} elseif ($linked->pivot->role === 'member') {
|
||||
// Nur den Read-only-Default hochstufen; owner/responsible bleiben.
|
||||
$user->companies()->updateExistingPivot($companyId, ['role' => 'responsible']);
|
||||
}
|
||||
}
|
||||
|
||||
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
|
||||
if ($contacts->isNotEmpty()) {
|
||||
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,14 +48,26 @@ PM-Zugriff war hart an `user_id` (Autor) gebunden. Jetzt **additiv**: Zugriff =
|
|||
|
||||
---
|
||||
|
||||
## 4. Magic-Link-Zugang für Pressekontakte (WS-2)
|
||||
## 4. Anmeldung per E-Mail-Link – zusammengeführt (WS-2)
|
||||
|
||||
Öffentliches Formular `/pressekontakt-zugang` (`auth.contact-access`):
|
||||
- **Enumeration-sicher** (neutrale Antwort unabhängig vom Treffer), **Honeypot** + **Rate-Limit**.
|
||||
- Eine hinterlegte Kontakt-E-Mail → **lazy** angelegter, per E-Mail **de-duplizierter** `customer`-Account (aktiv, verifiziert), der den Firmen seiner Kontakte als **Mitglied** zugeordnet wird.
|
||||
Eine einzige öffentliche Seite `/anmeldelink` (`auth.magic-link`) deckt **beide** Magic-Link-Fälle ab; das frühere Login-Modal und die separate Pressekontakt-Seite sind aufgelöst (war nicht selbsterklärend, zwei fast identische Wege). Die alte URL `/pressekontakt-zugang` leitet per Redirect auf `/anmeldelink`.
|
||||
|
||||
- **Enumeration-sicher** (neutrale Antwort unabhängig vom Treffer), **Honeypot** + **Rate-Limit** (pro E-Mail+IP 3/h, pro IP 15/h).
|
||||
- Einheitlicher Service-Eintritt `ContactAccessService::requestMagicAccess()`. Berücksichtigt **jede** Stelle, an der eine E-Mail hinterlegt ist – an einer **Firma** (`companies.email`) **und** an einem **Pressekontakt** (`contacts.email`), **portalübergreifend** (Abfragen ohne `PortalScope`):
|
||||
- **Bestehendes aktives Konto** → Login-Magic-Link (auch ohne Hinterlegung – ersetzt das alte Login-Modal).
|
||||
- **Hinterlegte Firma/Kontakt(e) ohne Konto** → **lazy** angelegter, per E-Mail **de-duplizierter** `customer`-Account (aktiv, verifiziert).
|
||||
- **Mehrfach hinterlegt** → **genau ein** Konto je E-Mail, dem **alle** zugehörigen Firmen und Pressekontakte zugeordnet werden – auch über Portalgrenzen hinweg.
|
||||
- **Pivot-Rolle `responsible`** („Verantwortlich"): Wer seine E-Mail an einer Firma/einem Pressekontakt hinterlegt hat, erhält **Schreibzugriff** auf die gesamte Firma (Stammdaten, Pressekontakte, Pressemitteilungen inkl. Terminierung/„Kalender") – siehe `CompanyPolicy::update()`. Owner (`owner_user_id`) bleiben unangetastet; ein bestehender reiner Lese-Pivot (`member`) wird beim nächsten Login/Magic-Access auf `responsible` hochgestuft. Greift über den Login-Re-Sync auf **allen** Anmeldewegen.
|
||||
- **Beides zugleich** → Konto wiederverwenden, Scope ergänzen, Link.
|
||||
- **Deaktiviertes Bestandskonto / unbekannte E-Mail** → keine Aktion, gleiche neutrale Meldung.
|
||||
- Versand über den **bestehenden Login-Magic-Link** (`MagicLinkGenerator` + `MagicLinkConsumeController`) – keine Schema-Änderung.
|
||||
|
||||
**Design-Nuance (bewusst):** Der Account wird beim **Absenden der Anfrage** angelegt (nicht erst beim Klick), um eine fragile `magic_links`-Schema-Änderung (ENUM/FK über SQLite-Tests + MySQL-Prod) zu vermeiden. Er ist „lazy" (nur für real existierende Kontakt-Mails) und ohne den per Mail versendeten Token wertlos.
|
||||
**Design-Nuance (bewusst):** Der Pressekontakt-Account wird beim **Absenden der Anfrage** angelegt (nicht erst beim Klick), um eine fragile `magic_links`-Schema-Änderung (ENUM/FK über SQLite-Tests + MySQL-Prod) zu vermeiden. Er ist „lazy" (nur für real existierende Kontakt-Mails) und ohne den per Mail versendeten Token wertlos. Die ältere Methode `requestAccess()` (nur Pressekontakte) bleibt für gezielte Aufrufe/Tests erhalten.
|
||||
|
||||
**Re-Sync bei jedem Login:** `App\Listeners\SyncCompanyMembershipsOnLogin` hört auf das `Login`-Event und ruft `ContactAccessService::syncMembershipsForEmail()` – greift damit bei **allen** Anmeldewegen (Magic-Link, Passwort, Google). Nachträglich (z. B. per API) hinzugekommene Firmen/Kontakte mit derselben E-Mail werden so beim nächsten Login automatisch zugeordnet, unabhängig vom Login-Weg. Rein additiv (kein Konto, keine Mail), bestehende Mitgliedschaften/Owner bleiben unangetastet.
|
||||
|
||||
- **Passwort-Login für Magic-Link-Accounts:** Lazy angelegte Accounts haben `password = null` und können sich (korrekt) nicht per Passwort anmelden, bis sie über **„Passwort vergessen"** eins setzen. Danach ist auch der reguläre Passwort-Login möglich.
|
||||
- **Performance-Hinweis:** Der Re-Sync fragt `companies`/`contacts` per `LOWER(email)` ab (case-insensitive, SQLite-Tests + MySQL-Prod). Bei sehr großen Tabellen pro Login besser über einen lowercased/funktionalen Index auf `email` absichern (Follow-up, noch nicht angelegt).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
|
||||
{{-- Der Auth-Bereich ist bewusst immer im Hellmodus; Dark/Light gibt es nur
|
||||
im eingeloggten Portal. Eine aus dem Portal übernommene .dark-Klasse
|
||||
(flux_appearance-Cookie / DOM-Übernahme) wird hier sofort entfernt,
|
||||
bevor die Styles greifen – sonst erscheint der Login dunkel. --}}
|
||||
<script>document.documentElement.classList.remove('dark');</script>
|
||||
|
||||
<title>{{ $pageTitle }}</title>
|
||||
|
||||
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}" />
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
<?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' => 'Pressemitteilung verwalten', 'eyebrow' => 'Zugang für Pressekontakte', 'topRightLabel' => 'Konto vorhanden?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
||||
#[Validate('required|string|email')]
|
||||
public string $email = '';
|
||||
|
||||
// Honeypot gegen einfache Bots – muss leer bleiben.
|
||||
public string $website = '';
|
||||
|
||||
public function requestAccess(ContactAccessService $contactAccess): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if ($this->website !== '') {
|
||||
// Bot: identische neutrale Antwort, keine Aktion.
|
||||
$this->sent();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureIsNotRateLimited();
|
||||
RateLimiter::hit($this->throttleKey(), 600);
|
||||
|
||||
$contactAccess->requestAccess($this->email, request()->ip());
|
||||
|
||||
$this->sent();
|
||||
}
|
||||
|
||||
private function sent(): void
|
||||
{
|
||||
$this->reset('email');
|
||||
session()->flash('status', __('Falls für diese E-Mail-Adresse ein Pressekontakt hinterlegt ist, haben wir Ihnen einen Zugangslink geschickt.'));
|
||||
}
|
||||
|
||||
protected function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', ['minutes' => ceil($seconds / 60)]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function throttleKey(): string
|
||||
{
|
||||
return 'contact-access|'.Str::transliterate(Str::lower($this->email).'|'.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">
|
||||
Sind Sie als Pressekontakt einer Firma hinterlegt? Geben Sie Ihre
|
||||
E-Mail-Adresse ein – wir senden Ihnen einen Link, mit dem Sie die
|
||||
Pressemitteilungen Ihrer Firma verwalten können.
|
||||
</p>
|
||||
|
||||
<form wire:submit="requestAccess" class="space-y-[18px]" novalidate>
|
||||
<div>
|
||||
<label class="field-label" for="contact-email">E-Mail-Adresse</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
wire:model="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="field-input"
|
||||
placeholder="kontakt@ihre-firma.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="contact-website">Website</label>
|
||||
<input id="contact-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="requestAccess">
|
||||
<span wire:loading.remove wire:target="requestAccess">Zugangslink anfordern</span>
|
||||
<span wire:loading wire:target="requestAccess">Wird gesendet …</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
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;
|
||||
|
|
@ -26,9 +22,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
|
||||
public bool $remember = false;
|
||||
|
||||
// Eigene Eingabe für das Magic-Link-Modal, getrennt vom Login-Formular.
|
||||
public string $magicEmail = '';
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
|
|
@ -101,38 +94,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
));
|
||||
}
|
||||
|
||||
public function sendMagicLink(): void
|
||||
{
|
||||
$this->validate(
|
||||
['magicEmail' => 'required|string|email'],
|
||||
attributes: ['magicEmail' => __('E-Mail-Adresse')],
|
||||
);
|
||||
|
||||
$this->ensureMagicLinkNotRateLimited();
|
||||
RateLimiter::hit($this->magicLinkThrottleKey(), 3600);
|
||||
RateLimiter::hit($this->magicLinkIpThrottleKey(), 3600);
|
||||
|
||||
$user = User::query()->where('email', $this->magicEmail)->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')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->reset('magicEmail');
|
||||
$this->dispatch('magic-link-sent');
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
@ -154,32 +115,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-Link-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 ensureMagicLinkNotRateLimited(): void
|
||||
{
|
||||
$withinEmail = ! RateLimiter::tooManyAttempts($this->magicLinkThrottleKey(), 3);
|
||||
$withinIp = ! RateLimiter::tooManyAttempts($this->magicLinkIpThrottleKey(), 15);
|
||||
|
||||
if ($withinEmail && $withinIp) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = max(
|
||||
RateLimiter::availableIn($this->magicLinkThrottleKey()),
|
||||
RateLimiter::availableIn($this->magicLinkIpThrottleKey()),
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'magicEmail' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'minutes' => max(1, ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
|
|
@ -187,19 +122,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
{
|
||||
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||
}
|
||||
|
||||
protected function magicLinkThrottleKey(): string
|
||||
{
|
||||
return 'magic-link|'.Str::transliterate(Str::lower($this->magicEmail).'|'.request()->ip());
|
||||
}
|
||||
|
||||
protected function magicLinkIpThrottleKey(): string
|
||||
{
|
||||
return 'magic-link-ip|'.request()->ip();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div x-data="{ magicModal: false }" x-on:magic-link-sent.window="magicModal = false">
|
||||
<div>
|
||||
@if (session('status'))
|
||||
<div class="field-status mb-4" role="status">
|
||||
{{ session('status') }}
|
||||
|
|
@ -274,17 +199,13 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
<span class="flex-1 h-px bg-bg-rule"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="magicModal = true; $nextTick(() => $refs.magicEmail?.focus())"
|
||||
class="auth-btn-outline !mt-0"
|
||||
>
|
||||
<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>Magic-Link senden</span>
|
||||
</button>
|
||||
<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">
|
||||
|
|
@ -296,59 +217,5 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
<span>Mit Google anmelden</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('contact-access.request') }}" class="block text-center text-[12px] text-ink-3 hover:text-hub transition-colors !mt-4" wire:navigate>
|
||||
Als Pressekontakt hinterlegt? Zugang anfordern →
|
||||
</a>
|
||||
</form>
|
||||
|
||||
{{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}}
|
||||
<div
|
||||
x-show="magicModal"
|
||||
x-cloak
|
||||
style="display: none"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
x-on:keydown.escape.window="magicModal = false"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/50" @click="magicModal = false"></div>
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-sm rounded-lg border border-bg-rule bg-bg-card p-6 shadow-xl"
|
||||
x-transition
|
||||
>
|
||||
<h3 class="text-[15px] font-semibold text-ink mb-1.5">Magic-Link anfordern</h3>
|
||||
<p class="text-[12.5px] text-ink-2 leading-[1.55] mb-4">
|
||||
Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Anmeldelink,
|
||||
mit dem Sie sich ohne Passwort anmelden können.
|
||||
</p>
|
||||
|
||||
<form wire:submit="sendMagicLink">
|
||||
<label class="field-label" for="magic-email">E-Mail-Adresse</label>
|
||||
<input
|
||||
id="magic-email"
|
||||
x-ref="magicEmail"
|
||||
type="email"
|
||||
wire:model="magicEmail"
|
||||
autocomplete="email"
|
||||
class="field-input"
|
||||
placeholder="redaktion@ihr-unternehmen.de"
|
||||
@error('magicEmail') aria-invalid="true" @enderror
|
||||
/>
|
||||
@error('magicEmail')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<div class="flex gap-2.5 !mt-4">
|
||||
<button type="button" class="auth-btn-outline !mt-0 flex-1" @click="magicModal = false">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="auth-btn-primary !mt-0 flex-1" wire:loading.attr="disabled" wire:target="sendMagicLink">
|
||||
<span wire:loading.remove wire:target="sendMagicLink">Link senden</span>
|
||||
<span wire:loading wire:target="sendMagicLink">Senden …</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
136
resources/views/livewire/auth/magic-link.blade.php
Normal file
136
resources/views/livewire/auth/magic-link.blade.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?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>
|
||||
|
|
@ -18,10 +18,14 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
|||
->middleware(['guest:'.config('fortify.guard')])
|
||||
->name('magic-links.consume');
|
||||
|
||||
// Magic-Link-Zugang für Pressekontakte ohne Account (WS-2)
|
||||
Volt::route('/pressekontakt-zugang', 'auth.contact-access')
|
||||
// Anmeldung per E-Mail-Link (Magic-Link): zusammengeführte Seite für
|
||||
// Bestandskonten UND hinterlegte Pressekontakte ohne Account (WS-2).
|
||||
Volt::route('/anmeldelink', 'auth.magic-link')
|
||||
->middleware(['guest:'.config('fortify.guard')])
|
||||
->name('contact-access.request');
|
||||
->name('magic-link.request');
|
||||
|
||||
// Alte Pressekontakt-URL weiterleiten (Bestandslinks/Lesezeichen).
|
||||
Route::redirect('/pressekontakt-zugang', '/anmeldelink');
|
||||
|
||||
// Google-Login (WS-6)
|
||||
Route::get('/auth/google/redirect', [GoogleController::class, 'redirect'])
|
||||
|
|
|
|||
|
|
@ -13,6 +13,19 @@ test('login screen can be rendered', function () {
|
|||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('the auth area forces light mode and never inherits the portal dark class', function () {
|
||||
/** @var TestCase $this */
|
||||
$portalUrl = rtrim((string) config('domains.domain_portal_url', 'http://pressekonto.test'), '/');
|
||||
|
||||
// Auch mit gesetztem Dark-Cookie aus dem Portal bleibt der Login hell:
|
||||
// das <html>-Tag trägt keine .dark-Klasse und das Strip-Skript ist präsent.
|
||||
$response = $this->withCookie('flux_appearance', 'dark')->get($portalUrl.'/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee("classList.remove('dark')", false);
|
||||
expect($response->getContent())->not->toContain('<html lang="en" class="dark"');
|
||||
});
|
||||
|
||||
test('users can authenticate using the login screen', function () {
|
||||
/** @var TestCase $this */
|
||||
// Super-Admin damit canAccessAdmin() === true → Login-Redirect auf /dashboard.
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ test('requesting access reuses an existing user without creating a duplicate', f
|
|||
Mail::assertSent(MagicLoginLink::class);
|
||||
});
|
||||
|
||||
test('the contact access form responds neutrally and triggers the service', function () {
|
||||
test('the magic link form responds neutrally and triggers the service for a contact', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
|
|
@ -85,9 +85,9 @@ test('the contact access form responds neutrally and triggers the service', func
|
|||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
Volt::test('auth.contact-access')
|
||||
Volt::test('auth.magic-link')
|
||||
->set('email', 'form@example.test')
|
||||
->call('requestAccess')
|
||||
->call('requestLink')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('email', '');
|
||||
|
||||
|
|
@ -95,6 +95,160 @@ test('the contact access form responds neutrally and triggers the service', func
|
|||
expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the magic link form sends a link to an existing active user without a contact', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create(['email' => 'plain@example.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
Volt::test('auth.magic-link')
|
||||
->set('email', 'plain@example.test')
|
||||
->call('requestLink')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('email', '');
|
||||
|
||||
Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
|
||||
});
|
||||
|
||||
test('requestMagicAccess does nothing for an unknown email', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$sent = app(ContactAccessService::class)->requestMagicAccess('nobody@example.test');
|
||||
|
||||
expect($sent)->toBeFalse();
|
||||
expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse();
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('requestMagicAccess does not send to an inactive existing user', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
User::factory()->create(['email' => 'off@example.test', 'is_active' => false]);
|
||||
|
||||
$sent = app(ContactAccessService::class)->requestMagicAccess('off@example.test');
|
||||
|
||||
expect($sent)->toBeFalse();
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a later-added company with the same email is linked on the next password login', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create(['email' => 'sync@example.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
// Firma kommt NACH dem Account dazu (z. B. per API) und trägt dieselbe E-Mail.
|
||||
$company = Company::factory()->presseecho()->create(['email' => 'sync@example.test']);
|
||||
|
||||
expect($user->accessibleCompanyIds())->not->toContain($company->id);
|
||||
|
||||
Volt::test('auth.login')
|
||||
->set('email', 'sync@example.test')
|
||||
->set('password', 'password')
|
||||
->call('login')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Der Login-Hook (SyncCompanyMembershipsOnLogin) hat die Firma verknüpft.
|
||||
expect($user->fresh()->accessibleCompanyIds())->toContain($company->id);
|
||||
});
|
||||
|
||||
test('a magic-link account can manage (edit) the company it was linked to', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$company = Company::factory()->presseecho()->create(['email' => 'verantwortlich@example.test']);
|
||||
|
||||
app(ContactAccessService::class)->requestMagicAccess('verantwortlich@example.test');
|
||||
|
||||
$user = User::query()->where('email', 'verantwortlich@example.test')->firstOrFail();
|
||||
|
||||
// Schreibzugriff (CompanyPolicy::update) – nicht nur Lesezugriff.
|
||||
expect($user->can('update', $company))->toBeTrue();
|
||||
expect($user->can('view', $company))->toBeTrue();
|
||||
});
|
||||
|
||||
test('an existing read-only member is upgraded to responsible on magic access', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$company = Company::factory()->presseecho()->create(['email' => 'upgrade@example.test']);
|
||||
|
||||
$user = User::factory()->create(['email' => 'upgrade@example.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
// Vorher nur Lesezugriff.
|
||||
$user->companies()->attach($company->id, ['role' => 'member']);
|
||||
|
||||
expect($user->can('update', $company))->toBeFalse();
|
||||
|
||||
app(ContactAccessService::class)->requestMagicAccess('upgrade@example.test');
|
||||
|
||||
expect($user->fresh()->can('update', $company))->toBeTrue();
|
||||
// Genau ein Pivot-Eintrag, jetzt 'responsible'.
|
||||
expect($user->companies()->withoutGlobalScopes()->whereKey($company->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a company email lazily creates an account scoped to that company', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'email' => 'firma@example.test',
|
||||
'name' => 'Firma AG',
|
||||
]);
|
||||
|
||||
// Gross-/Kleinschreibung egal.
|
||||
$sent = app(ContactAccessService::class)->requestMagicAccess('Firma@Example.test');
|
||||
|
||||
expect($sent)->toBeTrue();
|
||||
|
||||
$user = User::query()->where('email', 'firma@example.test')->firstOrFail();
|
||||
|
||||
expect($user->is_active)->toBeTrue();
|
||||
expect($user->hasVerifiedEmail())->toBeTrue();
|
||||
expect($user->hasRole('customer'))->toBeTrue();
|
||||
// Kein Kontaktname hinterlegt → Firmenname als Account-Name.
|
||||
expect($user->name)->toBe('Firma AG');
|
||||
expect($user->accessibleCompanyIds())->toContain($company->id);
|
||||
|
||||
Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
|
||||
});
|
||||
|
||||
test('one account is created for an email stored across multiple companies and contacts', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
// Dieselbe E-Mail an zwei Firmen (verschiedene Portale) direkt hinterlegt …
|
||||
$companyA = Company::factory()->presseecho()->create(['email' => 'multi@example.test']);
|
||||
$companyB = Company::factory()->businessportal24()->create(['email' => 'multi@example.test']);
|
||||
|
||||
// … und zusätzlich als Pressekontakt einer dritten Firma.
|
||||
$companyC = Company::factory()->presseecho()->create(['email' => 'andere@example.test']);
|
||||
Contact::factory()->for($companyC)->create([
|
||||
'email' => 'multi@example.test',
|
||||
'portal' => $companyC->portal->value,
|
||||
]);
|
||||
|
||||
$sent = app(ContactAccessService::class)->requestMagicAccess('multi@example.test');
|
||||
|
||||
expect($sent)->toBeTrue();
|
||||
|
||||
// Genau EIN Account für die E-Mail.
|
||||
expect(User::query()->where('email', 'multi@example.test')->count())->toBe(1);
|
||||
|
||||
$user = User::query()->where('email', 'multi@example.test')->firstOrFail();
|
||||
$ids = $user->accessibleCompanyIds();
|
||||
|
||||
// ALLE drei Firmen zugeordnet (zwei per Firmen-E-Mail, eine per Kontakt),
|
||||
// portalübergreifend.
|
||||
expect($ids)->toContain($companyA->id);
|
||||
expect($ids)->toContain($companyB->id);
|
||||
expect($ids)->toContain($companyC->id);
|
||||
|
||||
Mail::assertSent(MagicLoginLink::class, 1);
|
||||
});
|
||||
|
||||
test('requesting access does not mutate an existing inactive account', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
|
@ -126,10 +280,10 @@ test('the honeypot blocks bots without creating an account', function () {
|
|||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
Volt::test('auth.contact-access')
|
||||
Volt::test('auth.magic-link')
|
||||
->set('email', 'bot-target@example.test')
|
||||
->set('website', 'http://spam.example')
|
||||
->call('requestAccess')
|
||||
->call('requestLink')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ use Illuminate\Support\Facades\Mail;
|
|||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('user can request a magic login link from login page', function () {
|
||||
test('user can request a magic login link from the magic-link page', function () {
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink')
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', $user->email)
|
||||
->call('requestLink')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use ($user) {
|
||||
|
|
@ -27,25 +27,24 @@ test('user can request a magic login link from login page', function () {
|
|||
expect($magicLink->token_hash)->toHaveLength(64);
|
||||
});
|
||||
|
||||
test('the magic link modal validates its own email field and closes on success', function () {
|
||||
test('the magic link form validates its email field and clears on success', function () {
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
// Leere Eingabe → Validierungsfehler, keine Mail.
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', '')
|
||||
->call('sendMagicLink')
|
||||
->assertHasErrors(['magicEmail']);
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', '')
|
||||
->call('requestLink')
|
||||
->assertHasErrors(['email']);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
|
||||
// Gültige Eingabe → Feld wird geleert, Schließen-Event wird ausgelöst.
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink')
|
||||
// Gültige Eingabe → Feld wird geleert.
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', $user->email)
|
||||
->call('requestLink')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('magicEmail', '')
|
||||
->assertDispatched('magic-link-sent');
|
||||
->assertSet('email', '');
|
||||
});
|
||||
|
||||
test('magic link requests are rate limited per email', function () {
|
||||
|
|
@ -54,16 +53,16 @@ test('magic link requests are rate limited per email', function () {
|
|||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
foreach (range(1, 3) as $ignored) {
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink')
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', $user->email)
|
||||
->call('requestLink')
|
||||
->assertHasNoErrors();
|
||||
}
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink')
|
||||
->assertHasErrors(['magicEmail']);
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', $user->email)
|
||||
->call('requestLink')
|
||||
->assertHasErrors(['email']);
|
||||
|
||||
Mail::assertSent(MagicLoginLink::class, 3);
|
||||
});
|
||||
|
|
@ -75,9 +74,9 @@ test('admin can login with a valid magic link and lands on admin dashboard', fun
|
|||
$user = User::factory()->create(['is_active' => true]);
|
||||
$user->assignRole('admin');
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink');
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', $user->email)
|
||||
->call('requestLink');
|
||||
|
||||
$sentMail = null;
|
||||
|
||||
|
|
@ -107,9 +106,9 @@ test('customer is redirected to me dashboard after magic link login', function (
|
|||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $customer->email)
|
||||
->call('sendMagicLink');
|
||||
LivewireVolt::test('auth.magic-link')
|
||||
->set('email', $customer->email)
|
||||
->call('requestLink');
|
||||
|
||||
$sentMail = null;
|
||||
Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue