diff --git a/app/Models/User.php b/app/Models/User.php index ed650d1..6ca40d0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 + */ + 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); + } } diff --git a/app/Policies/PressReleasePolicy.php b/app/Policies/PressReleasePolicy.php index 476e5ad..09fd259 100644 --- a/app/Policies/PressReleasePolicy.php +++ b/app/Policies/PressReleasePolicy.php @@ -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; diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php new file mode 100644 index 0000000..b73b074 --- /dev/null +++ b/app/Services/Auth/ContactAccessService.php @@ -0,0 +1,117 @@ +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 $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 $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()); + } +} diff --git a/resources/views/livewire/auth/contact-access.blade.php b/resources/views/livewire/auth/contact-access.blade.php new file mode 100644 index 0000000..9187df0 --- /dev/null +++ b/resources/views/livewire/auth/contact-access.blade.php @@ -0,0 +1,105 @@ + '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()); + } +}; ?> + +
+ @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +

+ 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. +

+ +
+
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ + {{-- Honeypot: für Menschen unsichtbar --}} + + + +
+
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index e2b0854..0a955d7 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -221,6 +221,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu Magic-Link senden + + + Als Pressekontakt hinterlegt? Zugang anfordern → + {{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}} diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 009fcb4..71baee1 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -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); } diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index ec3389a..6bd441f 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -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')); diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index bb1b330..b324a0e 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -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', diff --git a/routes/auth.php b/routes/auth.php index 8f563c0..0f5aa67 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -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')]) diff --git a/tests/Feature/Auth/ContactAccessTest.php b/tests/Feature/Auth/ContactAccessTest.php new file mode 100644 index 0000000..8d6954e --- /dev/null +++ b/tests/Feature/Auth/ContactAccessTest.php @@ -0,0 +1,116 @@ +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(); +}); diff --git a/tests/Feature/PressReleaseCompanyScopeTest.php b/tests/Feature/PressReleaseCompanyScopeTest.php new file mode 100644 index 0000000..93edefb --- /dev/null +++ b/tests/Feature/PressReleaseCompanyScopeTest.php @@ -0,0 +1,80 @@ +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'); +});