Stop asking for a signature the law does not want, and lay out the tab

**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 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 17:30:55 +02:00
parent 976637afb2
commit 18193a731d
8 changed files with 143 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -203,8 +203,12 @@
<p class="text-md leading-relaxed text-body">
Welche Daten CluPilot zu welchem Zweck verarbeitet, steht in der
<a href="{{ route('legal.datenschutz') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">Datenschutzerklärung</a>.
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.
</p>
</section>

View File

@ -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')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-5 animate-rise [animation-delay:60ms]">
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
@if ($cancellationScheduled)
<div class="flex items-start gap-3 rounded border border-warning-border bg-warning-bg p-4">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
<div>
<p class="text-sm font-semibold text-ink">{{ __('settings.cancel_scheduled_title') }}</p>
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
</div>
{{-- Package, cancellation and while it is open the withdrawal. --}}
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-6 py-4">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
<p class="mt-0.5 text-sm text-muted">
@if ($cancellationScheduled)
{{ __('settings.cancel_scheduled_title') }}
@elseif ($hasActivePackage)
{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}
@else
{{ __('settings.no_package') }}
@endif
</p>
</div>
@elseif ($hasActivePackage)
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted">{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}</p>
@if ($hasActivePackage && ! $cancellationScheduled)
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
{{ __('settings.cancel_cta') }}
</x-ui.button>
@endif
</div>
@if ($cancellationScheduled)
<div class="flex items-start gap-3 border-b border-line bg-warning-bg px-6 py-4">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" />
<p class="text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
</div>
@else
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
@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)
<div class="border-t border-line pt-4">
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
<p class="mt-0.5 text-sm text-muted">
{{ __('withdrawal.card_sub', [
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
'days' => $withdrawal->daysLeft(),
]) }}
</p>
<x-ui.button variant="secondary" size="sm" class="mt-3"
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
<div class="min-w-0">
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
<p class="mt-0.5 max-w-[70ch] text-sm text-muted">
{{ __('withdrawal.card_sub', [
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
'days' => $withdrawal->daysLeft(),
]) }}
</p>
</div>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
{{ __('withdrawal.cta') }}
</x-ui.button>
</div>
@endif
</div>
</section>
{{-- ── 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)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs lg:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-3">
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-6 py-4">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('dpa.title') }}</h2>
<p class="mt-0.5 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('dpa.sub') }}</p>
<p class="mt-0.5 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('dpa.sub') }}</p>
</div>
@if ($dpaAcceptance)
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
<x-ui.icon name="check" class="size-3.5" />{{ __('dpa.state_accepted') }}
</span>
@else
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">
<x-ui.icon name="alert-triangle" class="size-3.5" />{{ __('dpa.state_open') }}
</span>
@endif
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border border-line bg-surface-2 px-2.5 py-0.5 font-mono text-xs text-body">
{{ __('dpa.version', ['version' => $dpa->version]) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2 px-6 py-4">
<x-ui.button :href="route('dpa.file', 'agreement')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="file-text" class="size-4" />{{ __('dpa.read_agreement') }}
</x-ui.button>
@ -377,38 +385,44 @@
</x-ui.button>
@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. --}}
<x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download_agreement') }}
</x-ui.button>
<span class="font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span>
@if ($dpa->measures_path)
<x-ui.button :href="route('dpa.file', ['which' => 'measures', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download_measures') }}
</x-ui.button>
@endif
</div>
@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. --}}
<p class="rounded border border-line bg-surface-2 p-4 text-sm leading-relaxed text-body">
{{ __('dpa.accepted_on', [
'version' => $dpa->version,
'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'),
]) }}
</p>
@else
<div class="rounded border border-accent-border bg-accent-subtle p-4">
<p class="text-sm leading-relaxed text-body">{{ __('dpa.accept_hint') }}</p>
<x-ui.button variant="primary" size="sm" class="mt-3"
wire:click="acceptProcessingAgreement" wire:loading.attr="disabled" wire:target="acceptProcessingAgreement">
{{ __('dpa.accept_cta') }}
</x-ui.button>
</div>
@endif
</div>
<div class="border-t border-line bg-surface-2 px-6 py-4">
@if ($dpaAcceptance)
{{-- R19: stored in UTC, read on the wall clock. --}}
<p class="flex flex-wrap items-center gap-2 text-sm text-body">
<x-ui.icon name="check" class="size-4 text-success" />
{{ __('dpa.accepted_on', [
'version' => $dpa->version,
'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'),
]) }}
</p>
@else
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('dpa.in_force_note') }}</p>
<x-ui.button variant="secondary" size="sm"
wire:click="acceptProcessingAgreement" wire:loading.attr="disabled" wire:target="acceptProcessingAgreement">
{{ __('dpa.accept_cta') }}
</x-ui.button>
</div>
@endif
</div>
</section>
@endif
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div class="flex flex-wrap items-start justify-between gap-3">
{{-- Closing the account, and the deadlines that close one by itself. --}}
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-6 py-4">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('settings.close_account_title') }}</h2>
<p class="mt-0.5 text-sm text-muted">{{ __('settings.close_account_sub') }}</p>
@ -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. --}}
<div class="rounded border border-line bg-surface-2 p-4">
<div class="px-6 py-4">
<p class="text-sm font-semibold text-ink">{{ __('settings.lifecycle_title') }}</p>
<ul class="mt-2 space-y-1.5 text-sm leading-relaxed text-muted">
<li>{{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}</li>
@ -437,7 +451,7 @@
{{ __('settings.lifecycle_terms') }}
</a>
</div>
</div>
</section>
</div>
@endif
</div>

View File

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