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

@ -330,4 +330,29 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasAnyRole(['admin', 'editor', 'customer']);
}
/**
* Firmen, auf die dieser User Zugriff hat: als Owner (owner_user_id) oder
* als Mitglied über den company_user-Pivot. Basis für das Firmen-Scoping
* von Pressemitteilungen (Zugriff = Autor ODER Firmenzugehörigkeit).
*
* @return list<int>
*/
public function accessibleCompanyIds(): array
{
return $this->ownedCompanies()->pluck('companies.id')
->merge($this->companies()->pluck('companies.id'))
->unique()
->values()
->all();
}
public function canAccessCompany(?int $companyId): bool
{
if ($companyId === null) {
return false;
}
return in_array($companyId, $this->accessibleCompanyIds(), true);
}
}

View file

@ -24,7 +24,7 @@ class PressReleasePolicy
return true;
}
return $this->isAuthor($user, $pressRelease);
return $this->canManage($user, $pressRelease);
}
public function create(User $user): bool
@ -34,7 +34,7 @@ class PressReleasePolicy
public function update(User $user, PressRelease $pressRelease): bool
{
if (! $this->isAuthor($user, $pressRelease) && ! $user->canAccessAdmin()) {
if (! $this->canManage($user, $pressRelease) && ! $user->canAccessAdmin()) {
return false;
}
@ -47,7 +47,7 @@ class PressReleasePolicy
public function submitForReview(User $user, PressRelease $pressRelease): bool
{
return $this->isAuthor($user, $pressRelease)
return $this->canManage($user, $pressRelease)
&& in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true);
}
@ -57,7 +57,7 @@ class PressReleasePolicy
return true;
}
return $this->isAuthor($user, $pressRelease)
return $this->canManage($user, $pressRelease)
&& $pressRelease->status !== PressReleaseStatus::Published;
}
@ -76,6 +76,17 @@ class PressReleasePolicy
return $user->canAccessAdmin() && $user->can('press-releases:publish');
}
/**
* Zugriff auf eine PM hat der Autor ODER ein Mitglied der zugeordneten
* Firma (Owner/Team-Mitglied). So sehen/bearbeiten Firmenkontakte inkl.
* der per Magic-Link lazy angelegten Accounts die PMs ihrer Firma.
*/
private function canManage(User $user, PressRelease $pressRelease): bool
{
return $this->isAuthor($user, $pressRelease)
|| $user->canAccessCompany($pressRelease->company_id);
}
private function isAuthor(User $user, PressRelease $pressRelease): bool
{
return $pressRelease->user_id === $user->id;

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());
}
}

View file

@ -0,0 +1,105 @@
<?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>

View file

@ -221,6 +221,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
</svg>
<span>Magic-Link senden</span>
</button>
<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 --}}

View file

@ -630,8 +630,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{
// Pro Livewire-Request memoisiert: mount(), with() und save() greifen
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
$user = auth()->user();
return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->where(function ($q) use ($user): void {
$q->where('user_id', $user->id);
$companyIds = $user->accessibleCompanyIds();
if ($companyIds !== []) {
$q->orWhereIn('company_id', $companyIds);
}
})
->findOrFail($this->id);
}

View file

@ -113,9 +113,16 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
$userId = auth()->id();
$context = app(CustomerCompanyContext::class);
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
// Firmen-Scope: eigene PMs (Autor) ODER PMs der zugeordneten Firmen.
$accessibleCompanyIds = auth()->user()->accessibleCompanyIds();
$base = PressRelease::withoutGlobalScopes()
->where('user_id', $userId)
->where(function ($q) use ($userId, $accessibleCompanyIds): void {
$q->where('user_id', $userId);
if ($accessibleCompanyIds !== []) {
$q->orWhereIn('company_id', $accessibleCompanyIds);
}
})
->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id'))
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id'));

View file

@ -176,8 +176,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
private function getMyPR(): PressRelease
{
$user = auth()->user();
return PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->where(function ($q) use ($user): void {
$q->where('user_id', $user->id);
$companyIds = $user->accessibleCompanyIds();
if ($companyIds !== []) {
$q->orWhereIn('company_id', $companyIds);
}
})
->with([
'company:id,name,email,phone,address,portal,logo_path,legacy_portal,is_active,type',
'category.translations',

View file

@ -17,6 +17,11 @@ 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')
->middleware(['guest:'.config('fortify.guard')])
->name('contact-access.request');
// Registrierung mit Livewire
Volt::route('/register', 'auth.register')
->middleware(['guest:'.config('fortify.guard')])

View file

@ -0,0 +1,116 @@
<?php
use App\Mail\MagicLoginLink;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt;
use Tests\TestCase;
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
test('requesting access for an unknown email creates nothing and sends no mail', function () {
/** @var TestCase $this */
Mail::fake();
$sent = app(ContactAccessService::class)->requestAccess('nobody@example.test');
expect($sent)->toBeFalse();
expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse();
Mail::assertNothingSent();
});
test('requesting access for a known contact lazily creates a scoped account and sends a link', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'paula@example.test',
'first_name' => 'Paula',
'last_name' => 'Presse',
'portal' => $company->portal->value,
]);
// Gross-/Kleinschreibung egal.
$sent = app(ContactAccessService::class)->requestAccess('Paula@Example.test');
expect($sent)->toBeTrue();
$user = User::query()->where('email', 'paula@example.test')->firstOrFail();
expect($user->name)->toBe('Paula Presse');
expect($user->is_active)->toBeTrue();
expect($user->hasVerifiedEmail())->toBeTrue();
expect($user->hasRole('customer'))->toBeTrue();
expect($user->canAccessAdmin())->toBeFalse();
expect($user->canAccessCustomer())->toBeTrue();
expect($user->accessibleCompanyIds())->toContain($company->id);
Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
});
test('requesting access reuses an existing user without creating a duplicate', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'existing@example.test',
'portal' => $company->portal->value,
]);
$existing = User::factory()->create(['email' => 'existing@example.test', 'is_active' => true]);
$existing->assignRole('customer');
app(ContactAccessService::class)->requestAccess('existing@example.test');
expect(User::query()->where('email', 'existing@example.test')->count())->toBe(1);
expect($existing->fresh()->accessibleCompanyIds())->toContain($company->id);
Mail::assertSent(MagicLoginLink::class);
});
test('the contact access form responds neutrally and triggers the service', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'form@example.test',
'portal' => $company->portal->value,
]);
Volt::test('auth.contact-access')
->set('email', 'form@example.test')
->call('requestAccess')
->assertHasNoErrors()
->assertSet('email', '');
Mail::assertSent(MagicLoginLink::class);
expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue();
});
test('the honeypot blocks bots without creating an account', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'bot-target@example.test',
'portal' => $company->portal->value,
]);
Volt::test('auth.contact-access')
->set('email', 'bot-target@example.test')
->set('website', 'http://spam.example')
->call('requestAccess')
->assertHasNoErrors();
Mail::assertNothingSent();
expect(User::query()->where('email', 'bot-target@example.test')->exists())->toBeFalse();
});

View file

@ -0,0 +1,80 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
test('a company member can view and manage press releases authored by a colleague', function () {
/** @var TestCase $this */
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$member = User::factory()->create(['is_active' => true]);
$member->assignRole('customer');
$company = Company::factory()->presseecho()->create(['owner_user_id' => $owner->id]);
$member->companies()->attach($company->id, ['role' => 'member']);
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
'user_id' => $owner->id,
'company_id' => $company->id,
'status' => 'draft',
]);
// Firmen-Scope: Mitglied darf, obwohl nicht Autor.
expect($member->can('view', $pressRelease))->toBeTrue();
expect($member->can('update', $pressRelease))->toBeTrue();
expect($member->can('submitForReview', $pressRelease))->toBeTrue();
expect($member->accessibleCompanyIds())->toContain($company->id);
});
test('a user outside the company still cannot access its press releases', function () {
/** @var TestCase $this */
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$outsider = User::factory()->create(['is_active' => true]);
$outsider->assignRole('customer');
$company = Company::factory()->presseecho()->create(['owner_user_id' => $owner->id]);
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
'user_id' => $owner->id,
'company_id' => $company->id,
'status' => 'draft',
]);
expect($outsider->can('view', $pressRelease))->toBeFalse();
expect($outsider->can('update', $pressRelease))->toBeFalse();
});
test('the me press release detail route resolves for a company member', function () {
/** @var TestCase $this */
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$member = User::factory()->create(['is_active' => true]);
$member->assignRole('customer');
$company = Company::factory()->presseecho()->create(['owner_user_id' => $owner->id]);
$member->companies()->attach($company->id, ['role' => 'member']);
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
'user_id' => $owner->id,
'company_id' => $company->id,
'title' => 'Firmen-PM eines Kollegen',
'status' => 'draft',
]);
$this->actingAs($member)
->get(route('me.press-releases.show', $pressRelease->id))
->assertSuccessful()
->assertSee('Firmen-PM eines Kollegen');
});