newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)

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) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 10:42:20 +00:00
parent a3e0042055
commit 0c1c072487
15 changed files with 665 additions and 37 deletions

View file

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

View file

@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup;
use App\Services\Company\NewsroomClaimService;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
/**
* Öffentliche Seite „Diesen Newsroom verwalten" (Roadmap 1b.2). Erreichbar
* für jede Firma mit Slug — auch ohne veröffentlichte Meldung, denn der
* Verwaltungszugang ist von der öffentlichen Sichtbarkeit unabhängig. Nur
* 410-entfernte Newsrooms bleiben Tombstone, aufgegangene führen zum
* Überlebenden.
*/
class NewsroomClaimController extends Controller
{
public function __construct(
private readonly NewsroomClaimService $claims,
private readonly MergedNewsroomLookup $mergedNewsrooms,
) {}
public function show(string $edition, string $slug): View|Response|RedirectResponse
{
$context = WebPortalContext::fromRequest();
$company = $this->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();
}
}

View file

@ -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,

View file

@ -12,6 +12,7 @@ use Illuminate\Support\Str;
class MagicLinkGenerator
{
/**
* @param array<string, mixed> $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,
]);

View file

@ -0,0 +1,53 @@
<?php
namespace App\Services\Company;
use App\Enums\CompanyMergeSubject;
use App\Models\Company;
use App\Models\CompanyMergeMove;
use App\Scopes\PortalScope;
use Illuminate\Database\Eloquent\Builder;
/**
* Wohin ein Newsroom-Slug führt, dessen Firma in einer Zusammenführung
* (Roadmap 1a.3) aufgegangen ist: zum Überlebenden. Gebraucht von der
* öffentlichen Newsroom-Seite (301) und vom Verwaltungszugang (1b.2) — beide
* müssen dieselbe Antwort geben.
*/
class MergedNewsroomLookup
{
/**
* Der Überlebende zu einem aufgegangenen Slug, oder null, wenn der Slug
* nie aufgegangen ist oder die Zusammenführung rückabgewickelt wurde.
*
* @param list<string> $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);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Services\Company;
use App\Enums\ConversionSource;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\Auth\ContactAccessService;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang zu einer
* Firma über die hinterlegte Firmen-E-Mail, unabhängig davon, ob der
* Newsroom öffentlich ist.
*
* Der Unterschied zur allgemeinen Anmeldung per E-Mail-Link: Der Besucher
* gibt KEINE Adresse ein. Der Link geht an das, was an der Firma hinterlegt
* ist — die Firmen-E-Mail, sonst die Pressekontakte. Wer das Postfach hat,
* verwaltet die Firma; wer es nicht hat, erfährt nur eine maskierte Adresse.
* Kein neues Zugangssystem: Konto, Zuordnung und Login laufen durch
* `ContactAccessService`, genau wie bei der Anmeldung über pressekonto.
*
* Die Herkunft `profile_claim` steht auf dem Token — so misst die
* Konversionsleiter, wie viele Newsrooms über diesen Weg beansprucht werden.
*/
class NewsroomClaimService
{
public function __construct(private readonly ContactAccessService $contactAccess) {}
/**
* Firma zu einem Slug auf diesen Portalen — auch ohne veröffentlichte
* Meldung, auch deaktiviert. Nicht aber 410-entfernt oder gelöscht: Die
* Firma existiert als Datensatz, aber nicht mehr als Newsroom.
*
* @param list<string> $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<int, string>
*/
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<int, string>
*/
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;
}
}

View file

@ -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<string, mixed>
*/
@ -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;
}
/**

View file

@ -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),
],
];

View file

@ -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**

View file

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

View file

@ -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',
],
];

View file

@ -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',
],
];

View file

@ -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')
<x-web.static-page
:eyebrow="__('static.newsroom_claim.eyebrow')"
:title="__('static.newsroom_claim.title')"
:breadcrumb="__('static.newsroom_claim.breadcrumb')"
>
<div class="max-w-[640px]">
<p class="text-[15px] font-semibold text-ink mb-4">{{ $company->name }}</p>
@if (session('newsroom-claim-status'))
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
{{ session('newsroom-claim-status') }}
</div>
@endif
@error('claim')
<div class="mb-6 px-4 py-3 border border-red-300 bg-red-50 text-[13px] text-red-700" role="alert">
{{ $message }}
</div>
@enderror
@if ($recipients->isEmpty() || session('newsroom-claim-none'))
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-6">
{{ __('static.newsroom_claim.intro_no_email', ['company' => $company->name]) }}
</p>
@else
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-4">
{{ __('static.newsroom_claim.intro', ['company' => $company->name]) }}
</p>
<ul class="mb-6 text-[14px] font-mono text-ink">
@foreach ($recipients as $recipient)
<li>{{ $recipient }}</li>
@endforeach
</ul>
<form method="POST" action="{{ route('newsroom.claim.store', ['slug' => $company->slug]) }}" class="space-y-5">
@csrf
{{-- Honeypot --}}
<div class="hidden" aria-hidden="true">
<label for="website">Website</label>
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" value="{{ old('website') }}" />
</div>
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
{{ __('static.newsroom_claim.submit') }}
</button>
</form>
@endif
<p class="mt-8 text-[12.5px] text-ink-2 leading-relaxed">
{{ __('static.newsroom_claim.wrong_address') }}
<a href="{{ rtrim((string) config('domains.domain_portal_url'), '/') }}/anmeldelink" class="underline underline-offset-2 hover:text-ink">{{ __('static.newsroom_claim.login_link') }}</a>
</p>
<p class="mt-4 text-[12.5px]">
<a href="{{ route('newsroom', ['slug' => $company->slug]) }}" class="underline underline-offset-2 text-ink-2 hover:text-ink">← {{ __('static.newsroom_claim.back') }}</a>
</p>
</div>
</x-web.static-page>
@endsection

View file

@ -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');

View file

@ -0,0 +1,154 @@
<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Enums\Portal;
use App\Mail\MagicLoginLink;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\Contact;
use App\Models\ConversionEvent;
use App\Models\MagicLink;
use App\Models\User;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyRemovalService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
/**
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang über die
* hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->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();
});