presseportale/app/Livewire/Concerns/InteractsWithAiDeclaration.php
Kevin Adametz 1d417f87f7 auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner,
  Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit
  granted_via = api; Befehl company-memberships:api-assignments (Liste,
  --remove mit Vorschau)
- K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort,
  remember_token und Sanctum-Tokens; SessionRevocation + Middleware
  EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich)
- K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at
  und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google
  ohne erzwungenes remember, magic-login ohne guest für eigene Links
- B-10: eigener Magic-Link-Zweck image_confirmation (Migration)
- K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden
  wird abgelehnt, Gate-Abfragen je Request zwischengespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

209 lines
7.9 KiB
PHP

<?php
namespace App\Livewire\Concerns;
use App\Enums\AiTextDeclaration;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Locked;
use RuntimeException;
/**
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
*
* Gemeinsame Properties + Validierung der vier Customer-Submit-Pfade
* (Erstellen, Bearbeiten, Detailansicht, Liste). Die Selbst-Deklaration ist
* Pflicht bei JEDEM Einreichen: ohne KI-Grad des Textes und verbindliche
* Bestätigung kein Submit. Die Bestätigung wird nicht vorbelegt — sie muss
* bei jedem Submit erneut gesetzt werden (Audit-Trail pro Einreichung).
*/
trait InteractsWithAiDeclaration
{
public ?string $aiTextDeclaration = null;
public bool $aiDeclarationConfirmed = false;
/**
* Zustimmungsgate (Bildkennzeichnung Block G): aktive Annahme der
* aktuellen AGB- und Richtlinien-Fassung, wenn das Gate an ist und eine
* Fassung noch nicht angenommen wurde.
*/
public bool $legalAcceptanceConfirmed = false;
/**
* Fassungen (type => version), die beim letzten Rendern im Submit-Modal
* standen. Der Schalter bestätigt genau diese; wechselt zwischen Anzeige
* und Absenden die Fassung, wird die Annahme abgelehnt und der neue
* Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). `#[Locked]`: der
* Browser kann die Werte nicht setzen (Umsetzungsaudit 29.09.2026, K-07).
*
* @var array<string, string>
*/
#[Locked]
public array $legalAcceptanceVersions = [];
/**
* Offene Fassungen für die Anzeige, einmal je Request ermittelt (OPT-7):
* Rendering-Hook und jedes Submit-Modal der Seite (die Liste hat eines
* je Zeile) teilen sich das Ergebnis. Nicht öffentlich, also nie Teil
* des Livewire-Zustands.
*
* @var Collection<int, LegalDocument>|null
*/
private ?Collection $legalDocumentsForDisplay = null;
/**
* Livewire-Trait-Hook: läuft nach der Aktion, unmittelbar vor dem
* Rendern — die Property trägt danach exakt die gezeigten Fassungen.
*/
public function renderingInteractsWithAiDeclaration(): void
{
$this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf($this->legalDocumentsToAccept());
}
/**
* Offene Fassungen, wie sie das Submit-Modal zeigt (Beschreibungsform
* aus LegalAcceptanceGate::describe()). Wird von den Seiten an
* `<x-press-release-submit-modal :legal-documents>` übergeben.
*
* @return Collection<int, array{type: string, version: string, label: string, url: string}>
*/
protected function legalDocumentsForSubmit(): Collection
{
$gate = app(LegalAcceptanceGate::class);
return $this->legalDocumentsToAccept()->map(fn (LegalDocument $document): array => $gate->describe($document))->values();
}
/**
* Ausgeschaltetes Gate: keine Abfrage. Fehlt bei aktivem Gate ein
* Pflichtdokument, zeigt die Seite trotzdem an (K-07) — die
* Konfigurationslücke wird gemeldet und erst das Absenden scheitert
* (validateAiDeclaration()).
*
* @return Collection<int, LegalDocument>
*/
private function legalDocumentsToAccept(): Collection
{
if ($this->legalDocumentsForDisplay !== null) {
return $this->legalDocumentsForDisplay;
}
$gate = app(LegalAcceptanceGate::class);
if (! $gate->isEnabled()) {
return $this->legalDocumentsForDisplay = collect();
}
try {
return $this->legalDocumentsForDisplay = $gate->requiredFor(auth()->user());
} catch (RuntimeException $exception) {
report($exception);
return $this->legalDocumentsForDisplay = collect();
}
}
/**
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
*/
protected function validateAiDeclaration(): void
{
$rules = [
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
'aiDeclarationConfirmed' => ['accepted'],
];
$messages = [
'aiTextDeclaration.required' => __('Bitte angeben, ob und wie generative KI beim Text eingesetzt wurde.'),
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
];
$required = $this->requiredLegalDocumentsForSubmit();
if ($required->isNotEmpty()) {
$rules['legalAcceptanceConfirmed'] = ['accepted'];
$messages['legalAcceptanceConfirmed.accepted'] = __('Bitte nehmen Sie die aktuelle Fassung der AGB und der Veröffentlichungsrichtlinien an.');
}
$this->validate($rules, $messages);
if ($required->isNotEmpty() && LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) {
$this->legalAcceptanceConfirmed = false;
throw ValidationException::withMessages([
'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'),
]);
}
}
/**
* Annahme protokollieren (nach validateAiDeclaration, vor dem Einreichen).
* Ohne Gate oder ohne offene Fassung passiert nichts.
*
* Wechselt die Fassung zwischen Validierung und Speichern, scheitert das
* Einreichen wie in validateAiDeclaration() — nicht still ohne Annahme
* weiter (K-07).
*/
protected function persistLegalAcceptances(): void
{
$required = $this->requiredLegalDocumentsForSubmit();
if ($required->isEmpty()) {
return;
}
if (! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) {
$this->legalAcceptanceConfirmed = false;
$this->legalDocumentsForDisplay = null;
throw ValidationException::withMessages([
'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'),
]);
}
app(LegalAcceptanceGate::class)->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent());
$this->legalAcceptanceConfirmed = false;
}
/**
* Verbindlicher, ungecachter Stand fürs Absenden. Ein fehlendes
* Pflichtdokument (Konfigurationsfehler) wird gemeldet und verhindert
* das Einreichen mit einer verständlichen Meldung statt eines 500ers.
*
* @return Collection<int, LegalDocument>
*
* @throws ValidationException
*/
private function requiredLegalDocumentsForSubmit(): Collection
{
try {
return app(LegalAcceptanceGate::class)->requiredFor(auth()->user());
} catch (RuntimeException $exception) {
report($exception);
throw ValidationException::withMessages([
'legalAcceptanceConfirmed' => __('Das Einreichen ist gerade nicht möglich, weil die aktuellen Bedingungen nicht geladen werden konnten. Bitte versuchen Sie es später erneut.'),
]);
}
}
/**
* Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur
* gestempelt, wenn die Checkbox tatsächlich gesetzt wurde.
*
* @return array{ai_text_declaration: ?string, ai_declaration_confirmed_at: ?Carbon}
*/
protected function aiDeclarationAttributes(): array
{
return [
'ai_text_declaration' => $this->aiTextDeclaration,
'ai_declaration_confirmed_at' => $this->aiDeclarationConfirmed ? now() : null,
];
}
}