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>
117 lines
3.8 KiB
PHP
117 lines
3.8 KiB
PHP
<?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());
|
||
}
|
||
}
|