From 0c1c0724871fd15a7aa711847c7e6640f348685a Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 15 Sep 2026 10:42:20 +0000 Subject: [PATCH] =?UTF-8?q?newsroom:=20Verwaltungszugang=20=E2=80=9EDiesen?= =?UTF-8?q?=20Newsroom=20verwalten"=20(Roadmap=201b.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService — neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde. Co-Authored-By: Claude Opus 5 (1M context) --- .../Auth/MagicLinkConsumeController.php | 15 +- .../Web/NewsroomClaimController.php | 126 ++++++++++++++ app/Services/Auth/ContactAccessService.php | 14 +- app/Services/Auth/MagicLinkGenerator.php | 3 + app/Services/Company/MergedNewsroomLookup.php | 53 ++++++ app/Services/Company/NewsroomClaimService.php | 107 ++++++++++++ app/Services/Web/NewsroomDetailPageData.php | 35 +--- config/newsroom.php | 15 ++ dev/2026-09-01/roadmap-2026.md | 6 +- dev/2026-09-01/umsetzung-protokoll.md | 60 +++++++ lang/de/static.php | 16 ++ lang/en/static.php | 16 ++ resources/views/web/newsroom-claim.blade.php | 75 +++++++++ routes/web.php | 7 + tests/Feature/Web/NewsroomClaimTest.php | 154 ++++++++++++++++++ 15 files changed, 665 insertions(+), 37 deletions(-) create mode 100644 app/Http/Controllers/Web/NewsroomClaimController.php create mode 100644 app/Services/Company/MergedNewsroomLookup.php create mode 100644 app/Services/Company/NewsroomClaimService.php create mode 100644 resources/views/web/newsroom-claim.blade.php create mode 100644 tests/Feature/Web/NewsroomClaimTest.php diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index fb6615b7..e9e53d55 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -7,13 +7,17 @@ use App\Http\Controllers\Controller; use App\Listeners\RecordAuthActivity; use App\Models\MagicLink; use App\Services\Conversion\ConversionRecorder; +use App\Services\Customer\CustomerCompanyContext; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class MagicLinkConsumeController extends Controller { - public function __construct(private readonly ConversionRecorder $recorder) {} + public function __construct( + private readonly ConversionRecorder $recorder, + private readonly CustomerCompanyContext $companies, + ) {} public function __invoke(Request $request, string $token): RedirectResponse { @@ -95,6 +99,15 @@ class MagicLinkConsumeController extends Controller ? route('me.dashboard', absolute: false) : '/'); + // „Diesen Newsroom verwalten" (Roadmap 1b.2): direkt auf die Firma, + // um die es ging — sofern der Account sie wirklich verwalten darf. + $landingCompanyId = (int) ($magicLink->payload['company_id'] ?? 0); + + if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin() + && $this->companies->findFor($user, $landingCompanyId) !== null) { + $home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false); + } + return redirect($home); } } diff --git a/app/Http/Controllers/Web/NewsroomClaimController.php b/app/Http/Controllers/Web/NewsroomClaimController.php new file mode 100644 index 00000000..c7aaa7d3 --- /dev/null +++ b/app/Http/Controllers/Web/NewsroomClaimController.php @@ -0,0 +1,126 @@ +claims->findClaimable($context->portalValues, $slug); + + if (! $company) { + return $this->resolveMissing($context, $edition, $slug); + } + + return view('web.newsroom-claim', [ + 'company' => $company, + 'recipients' => $this->claims->maskedRecipients($company), + ]); + } + + public function store(Request $request, string $edition, string $slug): RedirectResponse|Response + { + $context = WebPortalContext::fromRequest(); + $company = $this->claims->findClaimable($context->portalValues, $slug); + + if (! $company) { + return $this->resolveMissing($context, $edition, $slug); + } + + // Bot: neutral abnicken, bewusst vor allem anderen. + if (filled($request->input('website'))) { + return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent')); + } + + $this->ensureNotRateLimited($request, $company->getKey()); + RateLimiter::hit($this->ipKey($request), 3600); + RateLimiter::hit($this->companyKey($company->getKey()), 86400); + + if ($this->claims->recipients($company)->isEmpty()) { + return back()->with('newsroom-claim-none', true); + } + + // Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist + // oder nicht — die Adresse ist ohnehin nur maskiert sichtbar. + $this->claims->request($company, $request->ip()); + + return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent')); + } + + private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse + { + $survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug); + + if ($survivor !== null && $survivor->gone_at === null) { + // Host aus dem Request wie bei den anderen 301-Weiterleitungen — + // route() allein trüge den konfigurierten Root-Host. + return redirect( + request()->getSchemeAndHttpHost().route('newsroom.claim', ['edition' => $edition, 'slug' => $survivor->slug], absolute: false), + 301, + ); + } + + // 410-entfernt → Tombstone wie die Newsroom-Seite selbst; sonst 404. + $gone = Company::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', $context->portalValues) + ->where('slug', $slug) + ->whereNotNull('gone_at') + ->exists(); + + if ($gone) { + return response()->view('web.newsroom-tombstone', [], 410); + } + + abort(404); + } + + private function ensureNotRateLimited(Request $request, int $companyId): void + { + $perDay = (int) config('newsroom.claim.links_per_day', 3); + + if (RateLimiter::tooManyAttempts($this->companyKey($companyId), $perDay)) { + throw ValidationException::withMessages(['claim' => __('static.newsroom_claim.throttle_company')]); + } + + if (RateLimiter::tooManyAttempts($this->ipKey($request), 15)) { + throw ValidationException::withMessages(['claim' => __('static.newsroom_claim.throttle_ip')]); + } + } + + private function companyKey(int $companyId): string + { + return 'newsroom-claim|company|'.$companyId; + } + + private function ipKey(Request $request): string + { + return 'newsroom-claim|ip|'.$request->ip(); + } +} diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index afb2cb2e..820d5dd3 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -2,6 +2,7 @@ namespace App\Services\Auth; +use App\Enums\ConversionSource; use App\Mail\MagicLoginLink; use App\Models\Company; use App\Models\Contact; @@ -41,7 +42,7 @@ class ContactAccessService * - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet. * - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion. */ - public function requestMagicAccess(string $email, ?string $ip = null): bool + public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null): bool { $email = mb_strtolower(trim($email)); $contacts = $this->contactsFor($email); @@ -60,7 +61,7 @@ class ContactAccessService } $this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts); - $this->sendLoginLink($user, $ip); + $this->sendLoginLink($user, $ip, $source, $landingCompanyId); return true; } @@ -199,9 +200,14 @@ class ContactAccessService /** * Versendet einen regulären Login-Magic-Link an den Account. */ - private function sendLoginLink(User $user, ?string $ip): void + private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null): void { - $generated = $this->magicLinks->createLoginLink($user, $ip); + $generated = $this->magicLinks->createLoginLink( + $user, + $ip, + source: $source, + payload: $landingCompanyId !== null ? ['company_id' => $landingCompanyId] : [], + ); Mail::to($user->email)->queue((new MagicLoginLink( user: $user, diff --git a/app/Services/Auth/MagicLinkGenerator.php b/app/Services/Auth/MagicLinkGenerator.php index 60d4f8a6..f55cc6f6 100644 --- a/app/Services/Auth/MagicLinkGenerator.php +++ b/app/Services/Auth/MagicLinkGenerator.php @@ -12,6 +12,7 @@ use Illuminate\Support\Str; class MagicLinkGenerator { /** + * @param array $payload z. B. `company_id`, auf dessen Firmenseite der Login landen soll (Roadmap 1b.2) * @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon} */ public function createLoginLink( @@ -19,6 +20,7 @@ class MagicLinkGenerator ?string $requestedIp = null, int $ttlMinutes = 15, ?ConversionSource $source = null, + array $payload = [], ): array { $plainToken = Str::random(64); $tokenHash = hash('sha256', $plainToken); @@ -40,6 +42,7 @@ class MagicLinkGenerator // führt über zwei Domains und einen Mailclient, die Sitzung reißt // dabei genau an der interessanten Stelle (Roadmap 0.4). 'source' => $source?->value, + 'payload' => $payload ?: null, 'expires_at' => $expiresAt, 'ip_requested' => $requestedIp, ]); diff --git a/app/Services/Company/MergedNewsroomLookup.php b/app/Services/Company/MergedNewsroomLookup.php new file mode 100644 index 00000000..4cc62833 --- /dev/null +++ b/app/Services/Company/MergedNewsroomLookup.php @@ -0,0 +1,53 @@ + $portalValues + */ + public function survivorFor(array $portalValues, string $slug): ?Company + { + $mergedIds = Company::onlyTrashed() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', $portalValues) + ->where('slug', $slug) + ->pluck('id'); + + if ($mergedIds->isEmpty()) { + return null; + } + + $survivorId = CompanyMergeMove::query() + ->where('subject', CompanyMergeSubject::Company->value) + ->whereIn('subject_id', $mergedIds) + ->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at')) + ->with('merge:id,survivor_company_id') + ->latest('id') + ->first() + ?->merge + ?->survivor_company_id; + + if ($survivorId === null) { + return null; + } + + return Company::query()->withoutGlobalScope(PortalScope::class)->find($survivorId); + } +} diff --git a/app/Services/Company/NewsroomClaimService.php b/app/Services/Company/NewsroomClaimService.php new file mode 100644 index 00000000..c566141b --- /dev/null +++ b/app/Services/Company/NewsroomClaimService.php @@ -0,0 +1,107 @@ + $portalValues + */ + public function findClaimable(array $portalValues, string $slug): ?Company + { + return Company::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', $portalValues) + ->where('slug', $slug) + ->whereNull('gone_at') + ->first(); + } + + /** + * Empfänger des Anmeldelinks: die Firmen-E-Mail, sonst die Adressen der + * Pressekontakte. Bewusst nicht beides — ein Klick eines Fremden soll + * nicht fünf Postfächer treffen. + * + * @return Collection + */ + public function recipients(Company $company): Collection + { + $companyEmail = Company::emailKey($company->email); + + if ($companyEmail !== null) { + return collect([$companyEmail]); + } + + $emails = []; + + foreach ($company->contacts()->withoutGlobalScope(PortalScope::class)->pluck('email') as $email) { + $key = Company::emailKey($email); + + if ($key !== null) { + $emails[$key] = $key; + } + } + + return collect(array_values($emails)); + } + + /** + * Verschickt die Anmeldelinks. Gibt zurück, an wie viele Adressen + * tatsächlich etwas ging — deaktivierte Konten bekommen nichts. + */ + public function request(Company $company, ?string $ip): int + { + $sent = 0; + + foreach ($this->recipients($company) as $email) { + if ($this->contactAccess->requestMagicAccess($email, $ip, ConversionSource::ProfileClaim, $company->getKey())) { + $sent++; + } + } + + return $sent; + } + + /** + * @return Collection + */ + public function maskedRecipients(Company $company): Collection + { + return $this->recipients($company)->map(fn (string $email): string => self::mask($email)); + } + + public static function mask(string $email): string + { + [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); + + return Str::substr($local, 0, 1).'***@'.$domain; + } +} diff --git a/app/Services/Web/NewsroomDetailPageData.php b/app/Services/Web/NewsroomDetailPageData.php index 128a5627..673c5505 100644 --- a/app/Services/Web/NewsroomDetailPageData.php +++ b/app/Services/Web/NewsroomDetailPageData.php @@ -2,12 +2,11 @@ namespace App\Services\Web; -use App\Enums\CompanyMergeSubject; use App\Models\Category; use App\Models\Company; -use App\Models\CompanyMergeMove; use App\Models\PressRelease; use App\Scopes\PortalScope; +use App\Services\Company\MergedNewsroomLookup; use App\Support\WebPageCache; use App\Support\WebPortalContext; use Illuminate\Database\Eloquent\Builder; @@ -22,6 +21,8 @@ use Illuminate\Support\Carbon; */ class NewsroomDetailPageData { + public function __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {} + /** * @return array */ @@ -80,37 +81,13 @@ class NewsroomDetailPageData */ private function mergedInto(array $portalValues, string $slug): ?string { - $mergedIds = Company::onlyTrashed() - ->withoutGlobalScope(PortalScope::class) - ->whereIn('portal', $portalValues) - ->where('slug', $slug) - ->pluck('id'); + $survivor = $this->mergedNewsrooms->survivorFor($portalValues, $slug); - if ($mergedIds->isEmpty()) { + if ($survivor === null || ! $survivor->is_active) { return null; } - $survivorId = CompanyMergeMove::query() - ->where('subject', CompanyMergeSubject::Company->value) - ->whereIn('subject_id', $mergedIds) - ->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at')) - ->with('merge:id,survivor_company_id') - ->latest('id') - ->first() - ?->merge - ?->survivor_company_id; - - if ($survivorId === null) { - return null; - } - - $target = Company::query() - ->withoutGlobalScope(PortalScope::class) - ->whereKey($survivorId) - ->where('is_active', true) - ->value('slug'); - - return $target !== null && $target !== $slug ? $target : null; + return $survivor->slug !== $slug ? $survivor->slug : null; } /** diff --git a/config/newsroom.php b/config/newsroom.php index 05bb2d51..7907ddb6 100644 --- a/config/newsroom.php +++ b/config/newsroom.php @@ -23,4 +23,19 @@ return [ 'collection_threshold' => (int) env('NEWSROOM_MERGE_COLLECTION_THRESHOLD', 3), ], + /* + |-------------------------------------------------------------------------- + | Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2) + |-------------------------------------------------------------------------- + | + | claim_links_per_day: Anmeldelinks je Newsroom und Tag. Der Versand geht + | an die hinterlegte Firmen-E-Mail, nicht an eine vom Besucher eingegebene + | — er darf trotzdem kein Werkzeug werden, fremde Postfächer zu fluten. + | + */ + + 'claim' => [ + 'links_per_day' => (int) env('NEWSROOM_CLAIM_LINKS_PER_DAY', 3), + ], + ]; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 35287c64..159b8d17 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -118,11 +118,11 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die | Nr. | Arbeitspaket | Aufwand | Abhängigkeit | |---|---|---|---| -| 1b.1 | Sichtbarkeitsregel: Newsroom öffentlich erst ab der ersten veröffentlichten Meldung, sonst nur Datensatz | S | keine | -| 1b.2 | Verwaltungszugang über Magic Link, unabhängig von der öffentlichen Sichtbarkeit | M | 1a.4 | +| ~~1b.1~~ | Sichtbarkeitsregel: Newsroom öffentlich erst ab der ersten veröffentlichten Meldung, sonst nur Datensatz — **bestand; 15.09.2026 Rückbau im Cache vervollständigt** | S | keine | +| ~~1b.2~~ | Verwaltungszugang über Magic Link, unabhängig von der öffentlichen Sichtbarkeit — **gebaut 15.09.2026**: `/newsroom/{slug}/verwalten`, Link an die hinterlegte Firmen-E-Mail (maskiert), Herkunft `profile_claim`, Landung auf der Firmenseite; noch unverlinkt (→ 1b.4) | M | 1a.4 | | 1b.3 | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte | M | 1b.2 | | 1b.4 | Hinweis "Diesen Newsroom verwalten" auf der Profilseite und in der Veröffentlichungsmail | S | 1b.2 | -| 1b.5 | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit | S | 1b.1 | +| ~~1b.5~~ | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit — **15.09.2026**: alle Admin-Wege (archivieren, Status ändern, löschen) leeren jetzt den Seiten-Cache | S | 1b.1 | | 1b.6 | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen | S | Bounce-Suppression live | **Leitplanken Profilpflege** diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 7e2da6a5..165c08bc 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -895,3 +895,63 @@ Tests: `tests/Feature/Api/ApiNewsroomAssignmentTest.php` (9). und Namensvariante müssen bei den 7 aus den Slug-Kollisionen bleiben. Steigt die Zahl, trifft die Zuordnung nicht; `companies:merges` und der Aktivitätsverlauf zeigen, was zugeordnet wurde. + +--- + +# Teil 4 — Block 1b: Newsroom als Produkt (15.09.2026) + +## 1b.1 / 1b.5 — Sichtbarkeitsregel und Rückbau + +Die Regel bestand bereits: Detailseite 404 ohne veröffentlichte Meldung, +Verzeichnis, Sitemap und Hub-Zähler nur mit einer. Der Rückbau war aber +unvollständig: `depublish()` und `markGone()` leerten den Seiten-Cache, +`archive()`, `changeStatusFromAdmin()` (weg von published) und +`deleteFromAdmin()` einer veröffentlichten Meldung nicht — der Newsroom stand +nach der letzten Meldung bis zu eine Stunde weiter im Cache. Jetzt leeren alle +fünf Wege. Drei Tests in `NewsroomPageTest` halten den Rückbau fest. + +## 1b.2 — Verwaltungszugang über Magic Link + +``` +GET/POST /{edition}/newsroom/{slug}/verwalten +``` + +Der allgemeine Anmeldelink (`/anmeldelink` auf pressekonto, WS-2) bestand: +Wer eine E-Mail eingibt, die an einer Firma oder einem Pressekontakt steht, +bekommt lazy ein Konto mit Firmen-Scope. Was fehlte, war der Weg **vom +Newsroom aus**, ohne dass der Besucher eine Adresse kennt oder eingibt — +und unabhängig davon, ob der Newsroom überhaupt schon öffentlich ist. + +**`NewsroomClaimService` + `NewsroomClaimController`:** + +- Die Seite zeigt Firmenname und die **maskierte** Empfängeradresse + (`i***@muster.de`). Ein Klick schickt den Anmeldelink dorthin. Kein + Eingabefeld → kein Enumerationsproblem, keine fremde Adresse. +- Empfänger: die Firmen-E-Mail; fehlt sie, die Pressekontakte. Bewusst nicht + beides — ein Klick eines Fremden soll nicht fünf Postfächer treffen. +- Erreichbar für jede Firma mit Slug, **auch ohne veröffentlichte Meldung + und auch deaktiviert** (das ist der Punkt von 1b.2). 410-entfernte bleiben + Tombstone, aufgegangene (1a.3) führen per 301 zum Überlebenden — dieselbe + Auflösung wie die Newsroom-Seite, jetzt in `MergedNewsroomLookup`. +- Konto, Zuordnung (`responsible`) und Versand laufen durch + `ContactAccessService::requestMagicAccess()` — kein zweites Zugangssystem. + Neu dort: Herkunft und Ziel-Firma werden durchgereicht. +- **Herkunft `profile_claim` auf dem Token** — Stufe 2 der Konversionsleiter + misst damit, wie viele Newsrooms über diesen Weg beansprucht werden. +- **Landung:** Der Login führt direkt auf die Firmenseite im Kundenbereich + (`me.press-kits.show`), nicht aufs Dashboard — `company_id` liegt im + `payload` des Tokens, kein Schema-Eingriff. +- Drosselung: 3 Links je Newsroom und Tag (`config/newsroom.php`, + `NEWSROOM_CLAIM_LINKS_PER_DAY`), 15 je IP und Stunde, Honeypot. + +Tests: `tests/Feature/Web/NewsroomClaimTest.php` (7). + +**Noch nicht verlinkt.** Die Seite existiert, aber weder die Newsroom-Seite +noch die Veröffentlichungsmail zeigen darauf — das ist 1b.4 und hängt an E4 +(ein Element pro Mail: Magic-Link-Hinweis oder Infoseite). + +**Zu 1b.3 vorab:** Die Firmenseite im Kundenbereich kann heute Name, +Adresse, E-Mail, Telefon, Website, Land, Logo und Pressekontakte pflegen. +Was 1b.3 ergänzt: Beschreibungstext (durch die Rot-Ebene), Bilder (mit +KI-Deklaration und Audit-Log), und die Leitplanke, dass Links im Profiltext +systemseitig gesetzt werden. diff --git a/lang/de/static.php b/lang/de/static.php index 69c8949b..658ed6b3 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -537,4 +537,20 @@ return [ 'dispute_text' => 'Der Anbieter ist nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). Die frühere Plattform der EU-Kommission zur Online-Streitbeilegung wurde zum 20. Juli 2025 eingestellt.', ], + 'newsroom_claim' => [ + 'title' => 'Diesen Newsroom verwalten', + 'meta' => 'Verwaltungszugang zum Newsroom über die hinterlegte Firmen-E-Mail — kostenlos, ohne Passwort.', + 'eyebrow' => 'Newsroom', + 'breadcrumb' => 'Verwalten', + 'intro' => 'Der Newsroom von :company gehört dem Unternehmen selbst. Wer die hinterlegte Firmen-E-Mail liest, kann Profil, Logo und Pressekontakte pflegen — kostenlos und ohne Passwort. Wir senden einen Anmeldelink an:', + 'intro_no_email' => 'Für den Newsroom von :company ist keine E-Mail-Adresse hinterlegt. Bitte wenden Sie sich über das Kontaktformular an uns.', + 'submit' => 'Anmeldelink senden', + 'flash_sent' => 'Falls die hinterlegte Adresse erreichbar ist, haben wir einen Anmeldelink verschickt. Er ist 15 Minuten gültig.', + 'throttle_company' => 'Für diesen Newsroom wurden heute bereits mehrere Anmeldelinks angefordert. Bitte prüfen Sie das Postfach oder versuchen Sie es morgen erneut.', + 'throttle_ip' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.', + 'wrong_address' => 'Die Adresse stimmt nicht mehr? Dann melden Sie sich mit der Adresse an, die Sie erreichen können — sofern sie an einem Pressekontakt hinterlegt ist — oder schreiben Sie uns über das Kontaktformular.', + 'login_link' => 'Anmeldung per E-Mail-Link', + 'back' => 'Zum Newsroom', + ], + ]; diff --git a/lang/en/static.php b/lang/en/static.php index 01aa3c24..a2d0fb63 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -536,4 +536,20 @@ return [ 'dispute_text' => 'The provider is neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board (Section 36 VSBG). The former EU Commission platform for online dispute resolution was discontinued on 20 July 2025.', ], + 'newsroom_claim' => [ + 'title' => 'Manage this newsroom', + 'meta' => 'Administrative access to the newsroom via the registered company e-mail — free, no password.', + 'eyebrow' => 'Newsroom', + 'breadcrumb' => 'Manage', + 'intro' => 'The newsroom of :company belongs to the company itself. Whoever reads the registered company e-mail can maintain profile, logo and press contacts — free of charge and without a password. We will send a login link to:', + 'intro_no_email' => 'No e-mail address is registered for the newsroom of :company. Please contact us via the contact form.', + 'submit' => 'Send login link', + 'flash_sent' => 'If the registered address is reachable, we have sent a login link. It is valid for 15 minutes.', + 'throttle_company' => 'Several login links have already been requested for this newsroom today. Please check the mailbox or try again tomorrow.', + 'throttle_ip' => 'Too many requests. Please try again later.', + 'wrong_address' => 'The address is no longer correct? Then sign in with the address you can reach — provided it is registered with a press contact — or write to us via the contact form.', + 'login_link' => 'Sign in via e-mail link', + 'back' => 'To the newsroom', + ], + ]; diff --git a/resources/views/web/newsroom-claim.blade.php b/resources/views/web/newsroom-claim.blade.php new file mode 100644 index 00000000..d03ef3dc --- /dev/null +++ b/resources/views/web/newsroom-claim.blade.php @@ -0,0 +1,75 @@ +@extends('web.layouts.web-master') + +@section('title', __('static.newsroom_claim.title')) +@section('meta_description', __('static.newsroom_claim.meta')) + +{{-- + „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang über die + hinterlegte Firmen-E-Mail. Der Besucher gibt keine Adresse ein — der Link + geht an das, was an der Firma steht, und er sieht nur die maskierte + Adresse. Bewusst kein Angebot, kein Preis: Der Zugang ist kostenlos, und + die Seite bleibt es auch (Anti-Zombie-Zusage). +--}} +@section('content') + +
+

{{ $company->name }}

+ + @if (session('newsroom-claim-status')) +
+ {{ session('newsroom-claim-status') }} +
+ @endif + + @error('claim') + + @enderror + + @if ($recipients->isEmpty() || session('newsroom-claim-none')) +

+ {{ __('static.newsroom_claim.intro_no_email', ['company' => $company->name]) }} +

+ @else +

+ {{ __('static.newsroom_claim.intro', ['company' => $company->name]) }} +

+ +
    + @foreach ($recipients as $recipient) +
  • {{ $recipient }}
  • + @endforeach +
+ +
+ @csrf + + {{-- Honeypot --}} + + + +
+ @endif + +

+ {{ __('static.newsroom_claim.wrong_address') }} + {{ __('static.newsroom_claim.login_link') }} +

+ +

+ ← {{ __('static.newsroom_claim.back') }} +

+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 25d3b39f..5c0ad316 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Web\CategoryController; use App\Http\Controllers\Web\ContactController; use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\LegacyMediaRedirectController; +use App\Http\Controllers\Web\NewsroomClaimController; use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\PreiseController; @@ -242,6 +243,12 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func Route::get('suche', SucheController::class)->name('suche'); Route::get('newsrooms', NewsroomsController::class)->name('newsrooms'); Route::get('newsroom/{slug}', NewsroomDetailController::class)->name('newsroom'); + // „Diesen Newsroom verwalten" (Roadmap 1b.2): Anmeldelink an die + // hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit. + Route::get('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'show'])->name('newsroom.claim'); + Route::post('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'store']) + ->middleware('throttle:10,1') + ->name('newsroom.claim.store'); Route::view('api', 'web.api')->name('api'); Route::view('hilfe', 'web.hilfe')->name('hilfe'); Route::view('impressum', 'web.impressum')->name('impressum'); diff --git a/tests/Feature/Web/NewsroomClaimTest.php b/tests/Feature/Web/NewsroomClaimTest.php new file mode 100644 index 00000000..9d8a0db2 --- /dev/null +++ b/tests/Feature/Web/NewsroomClaimTest.php @@ -0,0 +1,154 @@ +seed(RolesAndPermissionsSeeder::class); + Mail::fake(); + RateLimiter::clear('newsroom-claim|ip|127.0.0.1'); +}); + +function claimCompany(array $attributes = []): Company +{ + return Company::factory()->create(array_merge([ + 'portal' => Portal::Businessportal24->value, + 'name' => 'Muster GmbH', + 'slug' => 'muster-gmbh', + 'email' => 'info@muster.de', + 'is_active' => true, + ], $attributes)); +} + +function claimUrl(string $slug = 'muster-gmbh'): string +{ + return 'https://businessportal24.test/de/newsroom/'.$slug.'/verwalten'; +} + +test('the claim page is reachable without a single published release and shows only a masked address', function () { + /** @var TestCase $this */ + claimCompany(); + + // Die öffentliche Newsroom-Seite gibt es noch nicht — den Zugang schon. + $this->get('https://businessportal24.test/de/newsroom/muster-gmbh')->assertNotFound(); + + $this->get(claimUrl()) + ->assertOk() + ->assertSeeText('Muster GmbH') + ->assertSeeText('i***@muster.de') + ->assertDontSeeText('info@muster.de'); +}); + +test('requesting the link creates the account, sends the login link with profile_claim on the token and lands on the company page', function () { + /** @var TestCase $this */ + $company = claimCompany(); + + $this->post(claimUrl())->assertRedirect()->assertSessionHas('newsroom-claim-status'); + + $user = User::query()->where('email', 'info@muster.de')->firstOrFail(); + expect($user->accessibleCompanyIds())->toContain($company->id); + + Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); + + $link = MagicLink::query()->where('user_id', $user->id)->where('purpose', 'login')->latest('id')->firstOrFail(); + expect($link->source)->toBe(ConversionSource::ProfileClaim->value) + ->and($link->payload['company_id'])->toBe($company->id); + + // Token aus der Mail nachstellen: Der Login landet auf der Firma, nicht + // auf dem Dashboard — und Stufe 2 der Leiter trägt die Herkunft. + $plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH)); + $this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/')) + ->assertRedirect(route('me.press-kits.show', ['id' => $company->id], absolute: false)); + + expect(ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->first()->source) + ->toBe(ConversionSource::ProfileClaim); +}); + +test('without a company email the link goes to the press contacts', function () { + /** @var TestCase $this */ + $company = claimCompany(['email' => null]); + Contact::factory()->for($company)->create(['email' => 'Paula@muster.de', 'portal' => 'businessportal24']); + Contact::factory()->for($company)->create(['email' => 'peter@muster.de', 'portal' => 'businessportal24']); + + $this->get(claimUrl())->assertSeeText('p***@muster.de'); + $this->post(claimUrl())->assertSessionHas('newsroom-claim-status'); + + Mail::assertQueuedCount(2); +}); + +test('without any address the page explains it and sends nothing', function () { + /** @var TestCase $this */ + claimCompany(['email' => null]); + + $this->get(claimUrl())->assertOk()->assertSeeText('keine E-Mail-Adresse hinterlegt'); + $this->post(claimUrl())->assertSessionHas('newsroom-claim-none'); + + Mail::assertNothingQueued(); +}); + +test('links per newsroom are limited per day', function () { + /** @var TestCase $this */ + $company = claimCompany(); + RateLimiter::clear('newsroom-claim|company|'.$company->id); + config()->set('newsroom.claim.links_per_day', 2); + + $this->post(claimUrl())->assertSessionHas('newsroom-claim-status'); + $this->post(claimUrl())->assertSessionHas('newsroom-claim-status'); + $this->post(claimUrl())->assertSessionHasErrors('claim'); + + Mail::assertQueuedCount(2); +}); + +test('a removed newsroom answers 410 and a merged-away slug redirects to the survivor', function () { + /** @var TestCase $this */ + $gone = claimCompany(['slug' => 'weg-gmbh', 'email' => 'weg@muster.de']); + app(CompanyRemovalService::class)->markGone($gone); + + $this->get(claimUrl('weg-gmbh'))->assertStatus(410); + $this->post(claimUrl('weg-gmbh'))->assertStatus(410); + + // Zusammenführung: alte Hälfte auf PE mit anderem Slug geht im + // B24-Überlebenden auf → der Zugang folgt dem 301 der Newsroom-Seite. + claimCompany(); + claimCompany(['portal' => Portal::Presseecho->value, 'slug' => 'muster']); + CompanyEmailGroup::query()->create([ + 'email' => 'info@muster.de', 'classification' => 'dublette', 'companies_count' => 2, 'distinct_names' => 1, + 'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(), + ]); + $service = app(CompanyMergeService::class); + $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b'); + + $this->get('https://presseecho.test/de/newsroom/muster/verwalten') + ->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh/verwalten') + ->assertStatus(301); + + $this->get('https://businessportal24.test/de/newsroom/unbekannt/verwalten')->assertNotFound(); +}); + +test('the honeypot answers neutrally without sending', function () { + /** @var TestCase $this */ + claimCompany(); + + $this->post(claimUrl(), ['website' => 'http://spam'])->assertSessionHas('newsroom-claim-status'); + + Mail::assertNothingQueued(); +});