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
265d1fde53
commit
fec2148ebb
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue