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>
260 lines
9.5 KiB
PHP
260 lines
9.5 KiB
PHP
<?php
|
||
|
||
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 Firmen & Pressekontakte (WS-2).
|
||
*
|
||
* Ü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
|
||
{
|
||
public function __construct(
|
||
private readonly MagicLinkGenerator $magicLinks,
|
||
private readonly UserRolePermissionSyncService $roleSync,
|
||
) {}
|
||
|
||
/**
|
||
* 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 = $this->contactsFor($email);
|
||
|
||
if ($contacts->isEmpty()) {
|
||
return false;
|
||
}
|
||
|
||
$user = $this->resolveUser($email, $contacts, collect());
|
||
|
||
// Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte
|
||
// Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link
|
||
// auslösen. Frisch lazy angelegte Accounts sind aktiv und laufen weiter.
|
||
if (! $user->is_active) {
|
||
return false;
|
||
}
|
||
|
||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, collect()), $contacts);
|
||
$this->sendLoginLink($user, $ip);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
public function syncMembershipsForEmail(User $user): void
|
||
{
|
||
$email = mb_strtolower(trim((string) $user->email));
|
||
|
||
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,
|
||
'registration_type' => RegistrationType::Company->value,
|
||
'is_active' => true,
|
||
]);
|
||
|
||
$user->forceFill(['email_verified_at' => now()])->save();
|
||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||
|
||
return $user;
|
||
}
|
||
|
||
/**
|
||
* 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 linkMemberships(User $user, Collection $companyIds, Collection $contacts): void
|
||
{
|
||
foreach ($companyIds as $companyId) {
|
||
// Owner via owner_user_id → bereits voller Zugriff.
|
||
if ($user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->exists()) {
|
||
continue;
|
||
}
|
||
|
||
$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']);
|
||
}
|
||
}
|
||
|
||
if ($contacts->isNotEmpty()) {
|
||
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
|
||
}
|
||
}
|
||
}
|