WS-2: Firmen-Scope für PMs & Magic-Link-Zugang für Pressekontakte

Firmen-Scope (Fundament):
- PM-Zugriff war hart an user_id (Autor) gebunden. Jetzt additiv: Autor ODER
  Mitglied der zugeordneten Firma (Owner via owner_user_id oder company_user-
  Pivot). Geändert in PressReleasePolicy (canManage) sowie den Queries der
  Listen-, Show- und Edit-Komponenten. Helfer User::accessibleCompanyIds()/
  canAccessCompany(). Solo-Owner unverändert; Firmenmitglieder sehen/bearbeiten
  alle PMs ihrer Firma.

Magic-Link-Zugang für Pressekontakte (ContactAccessService):
- Öffentliches, enumeration-sicheres Formular (/pressekontakt-zugang) mit
  Honeypot + Rate-Limit. Eine hinterlegte Kontakt-E-Mail führt zu einem lazy
  angelegten, de-duplizierten customer-Account (aktiv, verifiziert über den
  Magic-Link-Kanal), der den Firmen seiner Kontakte als Mitglied zugeordnet
  wird. Versand über den bestehenden Login-Magic-Link (Generator + Consume
  wiederverwendet) – keine Schema-Änderung, kein paralleles System.
- Dezenter Einstiegslink von der Login-Seite (PM-Frontend-Wiring später).

Tests: PressReleaseCompanyScopeTest (3), ContactAccessTest (6, inkl. De-Dup,
Enumeration-Sicherheit, Honeypot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 08:33:12 +00:00
parent 94cb209a9f
commit 980763c362
11 changed files with 493 additions and 7 deletions

View file

@ -0,0 +1,117 @@
<?php
namespace App\Services\Auth;
use App\Enums\RegistrationType;
use App\Mail\MagicLoginLink;
use App\Models\Contact;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Mail;
/**
* Magic-Link-Zugang für 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.
*/
class ContactAccessService
{
public function __construct(
private readonly MagicLinkGenerator $magicLinks,
private readonly UserRolePermissionSyncService $roleSync,
) {}
/**
* 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).
*/
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();
if ($contacts->isEmpty()) {
return false;
}
$user = $this->resolveUser($email, $contacts);
$this->linkCompanies($user, $contacts);
if (! $user->is_active) {
return false;
}
$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'),
));
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
*/
private function resolveUser(string $email, Collection $contacts): User
{
$existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if ($existing) {
return $existing;
}
$contact = $contacts->first();
$name = trim(($contact->first_name ?? '').' '.($contact->last_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;
}
/**
* Ordnet den User den Firmen seiner Kontakte als Mitglied zu (Firmen-Scope)
* und verknüpft ihn mit den Kontakt-Datensätzen. Owner bleiben Owner.
*
* @param Collection<int, Contact> $contacts
*/
private function linkCompanies(User $user, 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();
if (! $alreadyLinked) {
$user->companies()->attach($companyId, ['role' => 'member']);
}
}
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
}
}