From 18193a731d6686ef43bff5b87a8567d70ca26663 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:30:55 +0200 Subject: [PATCH] Stop asking for a signature the law does not want, and lay out the tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The owner is right about the agreement.** Art. 28 wants a CONTRACT, not a ceremony — and a contract is concluded by incorporating the agreement into the terms the customer accepts at checkout, which is exactly how every hoster they have bought from does it. Nothing in the regulation asks for a second, separate click. What it does ask is that the agreement is in writing (electronic form included, Art. 28(9)), that it is the version in force, and that the customer can obtain it. So: the terms now say the agreement is part of the contract and needs no separate signing, and name where it is. The card states that rather than flagging the customer as outstanding — the warning chip is gone. The button stays, reworded to "Zusätzlich bestätigen": a practice or a firm that gets audited often wants an explicit record, and it costs a click. The console's counter says how many confirmed rather than how many are "open", because none of them are. **The contract tab was a staircase** — a short card, a wide one spanning both columns, then a short one again. It is one column of full-width sections now, each with the same shape: a header row carrying the state and its action, then the body. Nothing tracks sideways any more. The package section states its own state in the header instead of a paragraph in the body, the withdrawal sits under it as a row rather than a second card, and the agreement's four files each have a button — reading and keeping, agreement and measures, which the single "Herunterladen" could not express. Also: the generated version no longer carries "Aus dem Repository erzeugt (clupilot:publish-dpa)" in the console's list. That is the command line, not information. Co-Authored-By: Claude Opus 5 --- .../Commands/PublishProcessingAgreement.php | 1 - lang/de/dpa.php | 12 +- lang/de/dpa_admin.php | 4 +- lang/en/dpa.php | 12 +- lang/en/dpa_admin.php | 4 +- resources/views/legal/terms.blade.php | 8 +- resources/views/livewire/settings.blade.php | 168 ++++++++++-------- tests/Feature/ProcessingAgreementTest.php | 33 +++- 8 files changed, 143 insertions(+), 99 deletions(-) diff --git a/app/Console/Commands/PublishProcessingAgreement.php b/app/Console/Commands/PublishProcessingAgreement.php index 1e69d9f..4a97309 100644 --- a/app/Console/Commands/PublishProcessingAgreement.php +++ b/app/Console/Commands/PublishProcessingAgreement.php @@ -59,7 +59,6 @@ class PublishProcessingAgreement extends Command 'version' => $version, 'agreement_path' => $agreementPath, 'measures_path' => $measuresPath, - 'note' => 'Aus dem Repository erzeugt (clupilot:publish-dpa).', // Publishing is the point of the command; --draft is for looking at // it first. Every customer who accepted an earlier version is // outstanding again from this moment. diff --git a/lang/de/dpa.php b/lang/de/dpa.php index 7cd9a97..6f79b85 100644 --- a/lang/de/dpa.php +++ b/lang/de/dpa.php @@ -5,14 +5,14 @@ return [ 'title' => 'AV-Vertrag & TOM', 'sub' => 'Der Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO samt Dokumentation der technischen und organisatorischen Maßnahmen — für Ihre eigene Dokumentation und Prüfung.', - 'state_accepted' => 'Abgeschlossen', - 'state_open' => 'Offen', 'read_agreement' => 'AV-Vertrag ansehen', 'read_measures' => 'TOM ansehen', 'version' => 'Fassung :version', - 'accept_hint' => 'Bitte lesen Sie den Vertrag und schließen Sie ihn ab. Wir halten dazu Zeitpunkt, Fassung und IP-Adresse fest; eine Unterschrift auf Papier ist nach Art. 28 Abs. 9 DSGVO nicht nötig.', - 'accept_cta' => 'Zur Kenntnis genommen und abgeschlossen', - 'accepted_on' => 'Fassung :version abgeschlossen am :when. Bei einer neuen Fassung melden wir uns und bitten Sie erneut um Ihre Zustimmung.', - 'accepted_notice' => 'AV-Vertrag abgeschlossen. Sie finden ihn jederzeit hier.', + 'accept_cta' => 'Zusätzlich bestätigen', + 'accepted_on' => 'Fassung :version von Ihnen bestätigt am :when. Bei einer neuen Fassung informieren wir Sie.', + 'accepted_notice' => 'Bestätigung festgehalten. Den Vertrag finden Sie jederzeit hier.', 'download' => 'Herunterladen', + 'in_force_note' => 'Dieser Vertrag ist mit Ihrer Bestellung Bestandteil unserer Vereinbarung; eine gesonderte Unterschrift ist nicht nötig. Wenn Sie für Ihre Unterlagen eine ausdrückliche Bestätigung möchten, halten wir Fassung, Zeitpunkt und IP-Adresse fest.', + 'download_agreement' => 'AV-Vertrag laden', + 'download_measures' => 'TOM laden', ]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php index 1711fb0..20b1b2d 100644 --- a/lang/de/dpa_admin.php +++ b/lang/de/dpa_admin.php @@ -17,8 +17,8 @@ return [ 'current_title' => 'In Kraft', 'none_yet' => 'Noch keine Fassung veröffentlicht. Solange nichts in Kraft ist, sehen Ihre Kunden den Punkt nicht.', 'in_force_since' => 'seit :when', - 'accepted_count' => ':accepted von :total Kunden haben abgeschlossen.', - 'outstanding' => ':count offen', + 'accepted_count' => ':accepted von :total Kunden haben zusätzlich bestätigt.', + 'outstanding' => 'Eine Bestätigung ist nicht erforderlich — der Vertrag gilt mit den AGB.', 'versions_title' => 'Fassungen', 'no_versions' => 'Noch nichts hochgeladen.', 'col_version' => 'Fassung', diff --git a/lang/en/dpa.php b/lang/en/dpa.php index 598c85b..4c0b3a5 100644 --- a/lang/en/dpa.php +++ b/lang/en/dpa.php @@ -5,14 +5,14 @@ return [ 'title' => 'Processing agreement & TOMs', 'sub' => 'The data-processing agreement under Art. 28 GDPR together with the documentation of technical and organisational measures — for your own records and audits.', - 'state_accepted' => 'Concluded', - 'state_open' => 'Outstanding', 'read_agreement' => 'Read the agreement', 'read_measures' => 'Read the measures', 'version' => 'Version :version', - 'accept_hint' => 'Please read the agreement and conclude it. We record the moment, the version and the IP address; a signature on paper is not required (Art. 28(9) GDPR).', - 'accept_cta' => 'Read and concluded', - 'accepted_on' => 'Version :version concluded on :when. When a new version is issued we will tell you and ask again.', - 'accepted_notice' => 'Agreement concluded. You can find it here at any time.', + 'accept_cta' => 'Confirm additionally', + 'accepted_on' => 'Version :version confirmed by you on :when. We will tell you when a new version is issued.', + 'accepted_notice' => 'Confirmation recorded. The agreement stays available here.', 'download' => 'Download', + 'in_force_note' => 'This agreement became part of our contract with your order; a separate signature is not required. If you would like an explicit confirmation for your records, we note the version, the moment and the IP address.', + 'download_agreement' => 'Download agreement', + 'download_measures' => 'Download measures', ]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php index 96c1802..e1ee1bb 100644 --- a/lang/en/dpa_admin.php +++ b/lang/en/dpa_admin.php @@ -17,8 +17,8 @@ return [ 'current_title' => 'In force', 'none_yet' => 'No version published yet. While nothing is in force, customers do not see the section.', 'in_force_since' => 'since :when', - 'accepted_count' => ':accepted of :total customers have concluded it.', - 'outstanding' => ':count outstanding', + 'accepted_count' => ':accepted of :total customers confirmed it additionally.', + 'outstanding' => 'A confirmation is not required — the agreement applies with the terms.', 'versions_title' => 'Versions', 'no_versions' => 'Nothing uploaded yet.', 'col_version' => 'Version', diff --git a/resources/views/legal/terms.blade.php b/resources/views/legal/terms.blade.php index 08759f0..0fa6a86 100644 --- a/resources/views/legal/terms.blade.php +++ b/resources/views/legal/terms.blade.php @@ -203,8 +203,12 @@

Welche Daten CluPilot zu welchem Zweck verarbeitet, steht in der Datenschutzerklärung. - Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt dafür ein Vertrag über die - Auftragsverarbeitung nach Art. 28 DSGVO, den der Kunde anfordern kann. + Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt der Vertrag über die + Auftragsverarbeitung nach Art. 28 DSGVO in seiner jeweils geltenden Fassung. Er wird mit + diesen AGB Bestandteil des Vertrags; einer gesonderten Unterzeichnung bedarf es nicht. + Der Vertrag und die Dokumentation der technischen und organisatorischen Maßnahmen stehen + im Kundenbereich jederzeit zum Ansehen und Herunterladen bereit. Eine neue Fassung wird + dem Kunden mitgeteilt.

diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 48b924c..2663fd1 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -284,30 +284,43 @@ @endif {{-- ── Vertrag ──────────────────────────────────────────────────────── --}} + {{-- ── Vertrag ────────────────────────────────────────────────────── + One column of full-width sections, not a two-column grid with one card + spanning both: that produced a short box, a wide one, then a short one + again — a staircase with no rhythm to it. Each section here has the same + shape (a header row, then its body), so the eye can run down the page + instead of tracking sideways. --}} @if ($tab === 'contract') -
+
-
-

{{ __('settings.package_title') }}

- - @if ($cancellationScheduled) -
- -
-

{{ __('settings.cancel_scheduled_title') }}

-

{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}

-
+ {{-- Package, cancellation and — while it is open — the withdrawal. --}} +
+
+
+

{{ __('settings.package_title') }}

+

+ @if ($cancellationScheduled) + {{ __('settings.cancel_scheduled_title') }} + @elseif ($hasActivePackage) + {{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }} + @else + {{ __('settings.no_package') }} + @endif +

- @elseif ($hasActivePackage) -
-

{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}

+ @if ($hasActivePackage && ! $cancellationScheduled) {{ __('settings.cancel_cta') }} + @endif +
+ + @if ($cancellationScheduled) +
+ +

{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}

- @else -

{{ __('settings.no_package') }}

@endif {{-- The fourteen-day right of withdrawal. Shown only where it @@ -322,52 +335,47 @@ unwinds the contract itself, ends the service the same day and sends the money back. --}} @if ($withdrawal->applies && $withdrawal->open) -
-

{{ __('withdrawal.card_title') }}

-

- {{ __('withdrawal.card_sub', [ - 'date' => $withdrawal->endsAt->local()->isoFormat('LL'), - 'days' => $withdrawal->daysLeft(), - ]) }} -

- +
+

{{ __('withdrawal.card_title') }}

+

+ {{ __('withdrawal.card_sub', [ + 'date' => $withdrawal->endsAt->local()->isoFormat('LL'), + 'days' => $withdrawal->daysLeft(), + ]) }} +

+
+ {{ __('withdrawal.cta') }}
@endif -
+
- {{-- ── AV-Vertrag & TOM ────────────────────────────────────── - Art. 28(3) DSGVO wants a contract wherever personal data is - processed on somebody else's behalf, which is the whole of what - this product does. "In writing" there includes electronic form, - so the document plus a recorded acceptance is enough — and the - customer needs to be able to READ it without asking us for it, - because that is what the website promises. + {{-- ── AV-Vertrag & TOM ────────────────────────────────────────── + Art. 28 DSGVO wants a contract wherever personal data is + processed on somebody else's behalf. It is concluded WITH the + terms at checkout (see the AGB), so this is not a second signing + ceremony: the document has to be available, current, and + retrievable. An extra confirmation is offered because some + customers — a practice, a firm, anybody who gets audited — want + one on file, and it costs a click. - Nothing renders until an operator has published a version: a - card offering an agreement that does not exist would be worse - than the silence. --}} + Nothing renders until an operator has published a version. --}} @if ($dpa !== null) -
-
+
+

{{ __('dpa.title') }}

-

{{ __('dpa.sub') }}

+

{{ __('dpa.sub') }}

- @if ($dpaAcceptance) - - {{ __('dpa.state_accepted') }} - - @else - - {{ __('dpa.state_open') }} - - @endif + + {{ __('dpa.version', ['version' => $dpa->version]) }} +
-
+
{{ __('dpa.read_agreement') }} @@ -377,38 +385,44 @@ @endif {{-- Keep, not just read: the file carries the version in - its name, so "which fassung did I agree to" is - answerable from a downloads folder months later. --}} + its name, so which fassung applied is answerable from + a downloads folder months later. --}} - {{ __('dpa.download') }} + {{ __('dpa.download_agreement') }} - {{ __('dpa.version', ['version' => $dpa->version]) }} + @if ($dpa->measures_path) + + {{ __('dpa.download_measures') }} + + @endif
- @if ($dpaAcceptance) - {{-- What was agreed, when, and by whom — the same three - facts the register holds. R19: stored in UTC, read on - the wall clock. --}} -

- {{ __('dpa.accepted_on', [ - 'version' => $dpa->version, - 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), - ]) }} -

- @else -
-

{{ __('dpa.accept_hint') }}

- - {{ __('dpa.accept_cta') }} - -
- @endif -
+
+ @if ($dpaAcceptance) + {{-- R19: stored in UTC, read on the wall clock. --}} +

+ + {{ __('dpa.accepted_on', [ + 'version' => $dpa->version, + 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), + ]) }} +

+ @else +
+

{{ __('dpa.in_force_note') }}

+ + {{ __('dpa.accept_cta') }} + +
+ @endif +
+
@endif -
-
+ {{-- Closing the account, and the deadlines that close one by itself. --}} +
+

{{ __('settings.close_account_title') }}

{{ __('settings.close_account_sub') }}

@@ -425,7 +439,7 @@ nobody confirmed. The other rule — a year without a package — was nowhere at all. Both come from the commands that enforce them, so the page cannot drift from what actually happens. --}} -
+

{{ __('settings.lifecycle_title') }}

  • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • @@ -437,7 +451,7 @@ {{ __('settings.lifecycle_terms') }}
-
+
@endif
diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php index d01c630..f0ddc2e 100644 --- a/tests/Feature/ProcessingAgreementTest.php +++ b/tests/Feature/ProcessingAgreementTest.php @@ -61,8 +61,11 @@ it('offers the document and records the acceptance with its evidence', function $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']); + // The agreement is concluded WITH the terms at checkout, so the page states + // that rather than nagging — and offers the extra confirmation for whoever + // wants one on file. $page->assertSee(__('dpa.title')) - ->assertSee(__('dpa.state_open')) + ->assertSee(__('dpa.in_force_note')) ->assertSee(__('dpa.accept_cta')) ->call('acceptProcessingAgreement') ->assertDispatched('notify'); @@ -76,9 +79,9 @@ it('offers the document and records the acceptance with its evidence', function ->and($acceptance->accepted_at)->not->toBeNull() ->and($acceptance->ip)->not->toBeNull(); - // And the card now states it rather than asking again. + // And the card states the confirmation rather than asking again. Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) - ->assertSee(__('dpa.state_accepted')) + ->assertSee($acceptance->accepted_at->local()->isoFormat('LL, LT')) ->assertDontSee(__('dpa.accept_cta')); }); @@ -316,3 +319,27 @@ it('serves the console download too, not only the portal one', function () { ->assertOk() ->assertDownload('CluPilot-AV-Vertrag-3.3.pdf'); }); + +it('never treats the agreement as something outstanding', function () { + // Art. 28 wants a contract, not a ceremony: it is concluded with the terms + // at checkout (see the AGB), so the page must not imply that a customer who + // has not clicked is missing something. Hosters that ask for a separate + // signature are the exception, not the rule. + publishedDpa(); + [$user] = dpaCustomer(); + + $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])->html(); + + expect($page)->toContain(__('dpa.in_force_note')) + // No warning colour, no "open" chip: the state is "available", and it is. + ->and($page)->not->toContain('text-warning">'.__('dpa.title')); +}); + +it('says in the terms that the agreement comes with them', function () { + // Which is what makes the click optional rather than a gap. + $terms = $this->get(route('legal.agb'))->assertOk()->getContent(); + + expect($terms)->toContain('Auftragsverarbeitung') + ->and($terms)->toContain('gesonderten Unterzeichnung') + ->and($terms)->toContain('Kundenbereich'); +});