From 955f1b874f7f2db174fa5382e47b27e60474253e Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 16:56:57 +0200 Subject: [PATCH 01/12] Deliver the processing agreement, and hold the proof it was accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Art. 28(3) GDPR 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 (Art. 28(9)), so a document the customer can read plus a recorded acceptance is enough — no signature on paper. The website already promises "AV-Vertrag inklusive", which means it has to be obtainable without asking us for it. It was not obtainable at all. **The text is never this application's.** An operator uploads the document their lawyer wrote, names the version, and publishes it; the measures ride along as a second file, because they are an annex to the agreement and "which measures applied when this customer accepted" has to have one answer. Inventing the text here would have been worse than having none. **Uploading and publishing are two acts.** Acceptance is per version, so publishing leaves every customer who accepted the previous one outstanding again — correct, and far too expensive to trigger by dropping a file on a form. It goes through a confirmation modal that says exactly that (R23). **The customer's side** is a card in the contract tab: read the agreement, read the measures, one press to conclude it. What that press records is what makes it evidence rather than a flag — the version, the moment, the address it came from, and the login that pressed. Pressing twice is one agreement (unique index, not a check somebody can forget), and a superseded acceptance is kept rather than overwritten: it was true when it was made, and the history is the point. Nothing renders until a version is in force. A card offering an agreement that does not exist is worse than the silence. The files live on the private disk and are served through routes that check who is asking — an agreement is not a public asset, and a guessable URL to one would be a list of who our customers are. The customer route takes no version parameter: which document applies is ours to say. `dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps the platform running does not thereby decide what every customer is asked to agree to — and a capability written under `web` since the 2026-07-29 move lands in a guard nothing authenticates against, which is how this one first shipped answering 403 to a role that visibly had it. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/ConfirmPublishDpa.php | 45 ++++ app/Livewire/Admin/ProcessingAgreements.php | 129 +++++++++++ app/Livewire/Settings.php | 32 +++ app/Models/DpaAcceptance.php | 42 ++++ app/Models/DpaVersion.php | 60 +++++ app/Services/Legal/ProcessingAgreement.php | 78 +++++++ app/Support/Navigation.php | 4 + ...ocessing_agreement_and_who_accepted_it.php | 76 ++++++ ...1_101000_add_the_dpa_manage_permission.php | 40 ++++ lang/de/admin.php | 1 + lang/de/dpa.php | 17 ++ lang/de/dpa_admin.php | 35 +++ lang/en/admin.php | 1 + lang/en/dpa.php | 17 ++ lang/en/dpa_admin.php | 35 +++ .../admin/confirm-publish-dpa.blade.php | 15 ++ .../admin/processing-agreements.blade.php | 151 ++++++++++++ resources/views/livewire/settings.blade.php | 63 +++++ routes/admin.php | 20 ++ routes/web.php | 19 ++ tests/Feature/Admin/RbacMoveTest.php | 14 +- tests/Feature/ProcessingAgreementTest.php | 216 ++++++++++++++++++ 22 files changed, 1104 insertions(+), 6 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmPublishDpa.php create mode 100644 app/Livewire/Admin/ProcessingAgreements.php create mode 100644 app/Models/DpaAcceptance.php create mode 100644 app/Models/DpaVersion.php create mode 100644 app/Services/Legal/ProcessingAgreement.php create mode 100644 database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php create mode 100644 database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php create mode 100644 lang/de/dpa.php create mode 100644 lang/de/dpa_admin.php create mode 100644 lang/en/dpa.php create mode 100644 lang/en/dpa_admin.php create mode 100644 resources/views/livewire/admin/confirm-publish-dpa.blade.php create mode 100644 resources/views/livewire/admin/processing-agreements.blade.php create mode 100644 tests/Feature/ProcessingAgreementTest.php diff --git a/app/Livewire/Admin/ConfirmPublishDpa.php b/app/Livewire/Admin/ConfirmPublishDpa.php new file mode 100644 index 0000000..64ce619 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPublishDpa.php @@ -0,0 +1,45 @@ +authorize('dpa.manage'); + + $version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + + $this->uuid = $uuid; + $this->version = $version->version; + } + + public function confirm(): void + { + $this->authorize('dpa.manage'); + $this->dispatch('dpa-publish-confirmed', uuid: $this->uuid); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-publish-dpa'); + } +} diff --git a/app/Livewire/Admin/ProcessingAgreements.php b/app/Livewire/Admin/ProcessingAgreements.php new file mode 100644 index 0000000..a3fb285 --- /dev/null +++ b/app/Livewire/Admin/ProcessingAgreements.php @@ -0,0 +1,129 @@ +authorize('dpa.manage'); + } + + public function upload(): void + { + $this->authorize('dpa.manage'); + + $data = $this->validate([ + 'version' => 'required|string|max:64|unique:dpa_versions,version', + 'note' => 'nullable|string|max:2000', + // PDF only, and a real size limit: this is handed to customers as + // the contract between us, not as an editable file. + 'agreement' => 'required|file|mimes:pdf|max:10240', + 'measures' => 'nullable|file|mimes:pdf|max:10240', + ]); + + // The private disk. An agreement is not a public asset, and a guessable + // URL to one would be a list of who our customers are. + $version = DpaVersion::query()->create([ + 'version' => $data['version'], + 'note' => $data['note'] ?: null, + 'agreement_path' => $this->agreement->store('dpa', 'local'), + 'measures_path' => $this->measures?->store('dpa', 'local'), + 'created_by' => Auth::guard('operator')->id(), + ]); + + $this->reset('version', 'note', 'agreement', 'measures'); + + $this->dispatch('notify', message: __('dpa_admin.uploaded', ['version' => $version->version])); + } + + /** + * Put a version in force. + * + * Deliberately separate from the upload: every customer who had accepted the + * previous one is outstanding again from this moment, and that is not + * something to do by dropping a file on a form. + */ + #[On('dpa-publish-confirmed')] + public function publish(string $uuid): void + { + $this->authorize('dpa.manage'); + + $version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + + if ($version->isPublished()) { + return; + } + + $version->update(['published_at' => now()]); + + $this->dispatch('notify', message: __('dpa_admin.published', ['version' => $version->version])); + } + + public function render() + { + $this->authorize('dpa.manage'); + + $current = app(ProcessingAgreement::class)->current(); + + // How many customers are outstanding on the version in force. Counted + // against customers who are actually still customers — a closed record + // is not somebody we are waiting on. + $customers = Customer::query()->whereNull('closed_at')->count(); + $accepted = $current === null ? 0 : $current->acceptances()->count(); + + return view('livewire.admin.processing-agreements', [ + 'versions' => DpaVersion::query()->withCount('acceptances')->latest('id')->get(), + 'current' => $current, + 'customers' => $customers, + 'accepted' => $accepted, + 'outstanding' => max(0, $customers - $accepted), + ]); + } + + /** Whether a stored file is actually there, for the list. */ + public function exists(?string $path): bool + { + return $path !== null && Storage::disk('local')->exists($path); + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 1842afb..dd4c3eb 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -10,6 +10,7 @@ use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Services\Billing\CustomDomainAccess; use App\Services\Billing\WithdrawalRight; +use App\Services\Legal\ProcessingAgreement; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -396,6 +397,32 @@ class Settings extends Component * never seen this card can still name the method — so the refusal is made * where the mutation is, once, in the customer's own words. */ + /** + * Accept the processing agreement in force. + * + * One press, no dialogue: this is an agreement, not a deletion, and putting + * a confirmation in front of it would only make the record harder to obtain. + * The evidence — the moment, the address, the login — is taken by the + * service, so nothing here can record an acceptance from an address it + * invented. + */ + public function acceptProcessingAgreement(): void + { + $c = $this->requireCustomer(); + + if ($c === null) { + return; + } + + if (app(ProcessingAgreement::class)->accept($c) === null) { + // Nothing published yet. The card does not render in that case, so + // reaching this line means somebody posted to it directly. + return; + } + + $this->dispatch('notify', message: __('dpa.accepted_notice')); + } + #[On('withdrawal-confirmed')] public function withdraw(): void { @@ -482,6 +509,11 @@ class Settings extends Component // thing somebody can choose. An unrecorded customer sees neither // selected, which is the truth about their record. 'customerTypes' => Customer::TYPES, + // The processing agreement in force and this customer's acceptance + // of THAT version — an acceptance of a superseded one is history, + // not an answer (see ProcessingAgreement). + 'dpa' => ($dpaService = app(ProcessingAgreement::class))->current(), + 'dpaAcceptance' => $dpaService->acceptanceOf($c), ]); } } diff --git a/app/Models/DpaAcceptance.php b/app/Models/DpaAcceptance.php new file mode 100644 index 0000000..b715121 --- /dev/null +++ b/app/Models/DpaAcceptance.php @@ -0,0 +1,42 @@ + 'datetime']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function version(): BelongsTo + { + return $this->belongsTo(DpaVersion::class, 'dpa_version_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/DpaVersion.php b/app/Models/DpaVersion.php new file mode 100644 index 0000000..16bc583 --- /dev/null +++ b/app/Models/DpaVersion.php @@ -0,0 +1,60 @@ + 'datetime']; + } + + /** + * The version in force: the newest published one. + * + * Newest by publication, not by id — a version prepared earlier and + * published later is the later one, and the id says nothing about that. + */ + public static function current(): ?self + { + return static::query() + ->whereNotNull('published_at') + ->where('published_at', '<=', now()) + ->latest('published_at') + ->first(); + } + + public function isPublished(): bool + { + return $this->published_at !== null && $this->published_at->isPast(); + } + + public function acceptances(): HasMany + { + return $this->hasMany(DpaAcceptance::class); + } + + public function operator() + { + return $this->belongsTo(Operator::class, 'created_by'); + } +} diff --git a/app/Services/Legal/ProcessingAgreement.php b/app/Services/Legal/ProcessingAgreement.php new file mode 100644 index 0000000..b8e8e68 --- /dev/null +++ b/app/Services/Legal/ProcessingAgreement.php @@ -0,0 +1,78 @@ +current(); + + if ($customer === null || $current === null) { + return null; + } + + return DpaAcceptance::query() + ->where('customer_id', $customer->id) + ->where('dpa_version_id', $current->id) + ->first(); + } + + public function accepted(?Customer $customer): bool + { + return $this->acceptanceOf($customer) !== null; + } + + /** + * Record an acceptance. + * + * firstOrCreate over the pair the unique index covers: pressing twice is not + * two agreements, and a double click must not become a duplicate-key error + * in front of somebody agreeing to a contract. + * + * The address is read here rather than passed in, so no caller can record an + * acceptance as coming from somewhere it invented. + */ + public function accept(Customer $customer, ?string $ip = null): ?DpaAcceptance + { + $current = $this->current(); + + if ($current === null) { + return null; + } + + return DpaAcceptance::query()->firstOrCreate( + ['customer_id' => $customer->id, 'dpa_version_id' => $current->id], + [ + 'user_id' => Auth::id(), + 'ip' => $ip ?? request()->ip(), + 'accepted_at' => now(), + ], + ); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index d77f9b2..79d027e 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -76,6 +76,10 @@ final class Navigation // it is sent from, not under Betrieb: both are about how mail // leaves this installation. ['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'], + // The processing agreement. Under System with the other things + // that are true of the whole installation rather than of one + // customer. + ['admin.dpa', 'file-text', 'dpa', 'dpa.manage'], // System, not Betrieb: the tunnel is how the console reaches // the estate at all. Nothing is provisioned, monitored or // updated through anything else, so it belongs beside the other diff --git a/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php new file mode 100644 index 0000000..61f93f2 --- /dev/null +++ b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php @@ -0,0 +1,76 @@ +id(); + $table->uuid()->unique(); + // What the operator calls it — "1.0", "2026-08", whatever the + // document itself says. Unique, because two documents with one name + // makes every acceptance ambiguous. + $table->string('version', 64)->unique(); + $table->string('agreement_path'); + $table->string('measures_path')->nullable(); + $table->text('note')->nullable(); + // Null while it is being prepared. Only a published version is + // shown to anybody, and only the newest published one is current. + $table->timestamp('published_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('operators')->nullOnDelete(); + $table->timestamps(); + + $table->index('published_at'); + }); + + Schema::create('dpa_acceptances', function (Blueprint $table) { + $table->id(); + $table->uuid()->unique(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->foreignId('dpa_version_id')->constrained('dpa_versions')->cascadeOnDelete(); + // The login that pressed it, kept as evidence even when that user is + // later removed — the acceptance was still made. + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('ip', 45)->nullable(); + $table->timestamp('accepted_at'); + $table->timestamps(); + + // One acceptance per customer per version. Pressing twice is not two + // agreements, and the unique index is what makes that true rather + // than a check somebody can forget. + $table->unique(['customer_id', 'dpa_version_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('dpa_acceptances'); + Schema::dropIfExists('dpa_versions'); + } +}; diff --git a/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php new file mode 100644 index 0000000..42f4213 --- /dev/null +++ b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php @@ -0,0 +1,40 @@ +forgetCachedPermissions(); + + $permission = Permission::findOrCreate('dpa.manage', 'operator'); + + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'operator')->givePermissionTo($permission); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::where('name', 'dpa.manage')->where('guard_name', 'operator')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index e6fd38b..c8ed3fd 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'dpa' => 'AV-Vertrag', 'mail_preview' => 'E-Mail-Vorschau', 'inbox' => 'Posteingang', 'mail_log' => 'Postausgang', diff --git a/lang/de/dpa.php b/lang/de/dpa.php new file mode 100644 index 0000000..2760de6 --- /dev/null +++ b/lang/de/dpa.php @@ -0,0 +1,17 @@ + '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.', +]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php new file mode 100644 index 0000000..4ac6ec3 --- /dev/null +++ b/lang/de/dpa_admin.php @@ -0,0 +1,35 @@ + 'Rechtliches', + 'title' => 'AV-Vertrag & TOM', + 'subtitle' => 'Das Dokument, das Ihre Kunden nach Art. 28 DSGVO abschließen. Den Text laden Sie hoch — geschrieben wird er von Ihrer Rechtsberatung, nicht von dieser Anwendung.', + 'upload_title' => 'Neue Fassung hochladen', + 'upload_sub' => 'PDF, höchstens 10 MB. Die TOM sind eine Anlage zum Vertrag und werden mit derselben Fassung geführt.', + 'version' => 'Bezeichnung der Fassung', + 'version_hint' => 'Wie das Dokument selbst sie nennt — „1.0", „2026-08".', + 'note' => 'Interne Notiz', + 'note_hint' => 'Optional, nur für die Konsole.', + 'file_agreement' => 'AV-Vertrag (PDF)', + 'file_measures' => 'TOM (PDF)', + 'upload_cta' => 'Hochladen', + 'uploaded' => 'Fassung :version hochgeladen. Sie ist noch nicht in Kraft.', + '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', + 'versions_title' => 'Fassungen', + 'no_versions' => 'Noch nichts hochgeladen.', + 'col_version' => 'Fassung', + 'col_state' => 'Status', + 'col_accepted' => 'Abschlüsse', + 'col_files' => 'Dateien', + 'state_current' => 'In Kraft', + 'state_superseded' => 'Abgelöst', + 'state_draft' => 'Entwurf', + 'publish' => 'In Kraft setzen', + 'published' => 'Fassung :version ist in Kraft.', + 'publish_title' => 'Fassung :version in Kraft setzen?', + 'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index e008984..7b5bbd6 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'dpa' => 'Processing agreement', 'mail_preview' => 'Mail preview', 'inbox' => 'Inbox', 'mail_log' => 'Mail sent', diff --git a/lang/en/dpa.php b/lang/en/dpa.php new file mode 100644 index 0000000..c99e0e4 --- /dev/null +++ b/lang/en/dpa.php @@ -0,0 +1,17 @@ + '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.', +]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php new file mode 100644 index 0000000..5a97e5e --- /dev/null +++ b/lang/en/dpa_admin.php @@ -0,0 +1,35 @@ + 'Legal', + 'title' => 'Processing agreement & TOMs', + 'subtitle' => 'The document your customers conclude under Art. 28 GDPR. You upload the text — it is written by your lawyer, not by this application.', + 'upload_title' => 'Upload a new version', + 'upload_sub' => 'PDF, at most 10 MB. The measures are an annex to the agreement and are versioned with it.', + 'version' => 'Version label', + 'version_hint' => 'What the document itself calls it — "1.0", "2026-08".', + 'note' => 'Internal note', + 'note_hint' => 'Optional, console only.', + 'file_agreement' => 'Agreement (PDF)', + 'file_measures' => 'Measures (PDF)', + 'upload_cta' => 'Upload', + 'uploaded' => 'Version :version uploaded. It is not in force yet.', + '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', + 'versions_title' => 'Versions', + 'no_versions' => 'Nothing uploaded yet.', + 'col_version' => 'Version', + 'col_state' => 'State', + 'col_accepted' => 'Conclusions', + 'col_files' => 'Files', + 'state_current' => 'In force', + 'state_superseded' => 'Superseded', + 'state_draft' => 'Draft', + 'publish' => 'Put in force', + 'published' => 'Version :version is in force.', + 'publish_title' => 'Put version :version in force?', + 'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.', +]; diff --git a/resources/views/livewire/admin/confirm-publish-dpa.blade.php b/resources/views/livewire/admin/confirm-publish-dpa.blade.php new file mode 100644 index 0000000..79e75e0 --- /dev/null +++ b/resources/views/livewire/admin/confirm-publish-dpa.blade.php @@ -0,0 +1,15 @@ +
+
+ + + +
+

{{ __('dpa_admin.publish_title', ['version' => $version]) }}

+

{{ __('dpa_admin.publish_body') }}

+
+
+
+ {{ __('common.cancel') }} + {{ __('dpa_admin.publish') }} +
+
diff --git a/resources/views/livewire/admin/processing-agreements.blade.php b/resources/views/livewire/admin/processing-agreements.blade.php new file mode 100644 index 0000000..e52bad8 --- /dev/null +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -0,0 +1,151 @@ +{{-- + The processing agreement, in the console. + + Upload left, what is in force and who has it right, the versions underneath. + Uploading and publishing are two acts on purpose: publishing leaves every + customer outstanding again, and that is not something to trigger by dropping + a file on a form. +--}} +
+ +
+

{{ __('dpa_admin.eyebrow') }}

+

+ {{ __('dpa_admin.title') }} +

+

{{ __('dpa_admin.subtitle') }}

+
+ +
+ + {{-- ── Upload ─────────────────────────────────────────────────────── --}} +
+
+

{{ __('dpa_admin.upload_title') }}

+

{{ __('dpa_admin.upload_sub') }}

+
+ +
+ + +
+ +
+
+ + + @error('agreement')

{{ $message }}

@enderror +
+
+ + + @error('measures')

{{ $message }}

@enderror +
+
+ +
+ + {{ __('dpa_admin.upload_cta') }} + +
+
+ + {{-- ── What is in force ───────────────────────────────────────────── --}} +
+

{{ __('dpa_admin.current_title') }}

+ + @if ($current === null) +

{{ __('dpa_admin.none_yet') }}

+ @else +

{{ $current->version }}

+ {{-- R19: stored in UTC, read on the wall clock. --}} +

{{ __('dpa_admin.in_force_since', ['when' => $current->published_at->local()->isoFormat('LL')]) }}

+ +
+

+ {{ __('dpa_admin.accepted_count', ['accepted' => $accepted, 'total' => $customers]) }} +

+ @if ($outstanding > 0) +

{{ __('dpa_admin.outstanding', ['count' => $outstanding]) }}

+ @endif +
+ @endif +
+
+ + {{-- ── The versions ───────────────────────────────────────────────────── --}} +
+
+

{{ __('dpa_admin.versions_title') }}

+
+ + @if ($versions->isEmpty()) +

{{ __('dpa_admin.no_versions') }}

+ @else +
+ + + + + + + + + + + + @foreach ($versions as $v) + + + + + + + + @endforeach + +
{{ __('dpa_admin.col_version') }}{{ __('dpa_admin.col_state') }}{{ __('dpa_admin.col_accepted') }}{{ __('dpa_admin.col_files') }}
+ {{ $v->version }} + @if ($v->note) + {{ $v->note }} + @endif + + @if ($v->isPublished()) + + {{ $current?->id === $v->id ? __('dpa_admin.state_current') : __('dpa_admin.state_superseded') }} + + {{ $v->published_at->local()->isoFormat('LL') }} + @else + {{ __('dpa_admin.state_draft') }} + @endif + {{ $v->acceptances_count }} + + + @if (! $v->isPublished()) + {{-- R23: a modal, because publishing leaves every + customer outstanding again. --}} + + {{ __('dpa_admin.publish') }} + + @endif +
+
+ @endif +
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2b1d584..05242c8 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -338,6 +338,69 @@ @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. + + Nothing renders until an operator has published a version: a + card offering an agreement that does not exist would be worse + than the silence. --}} + @if ($dpa !== null) +
+
+
+

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

+

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

+
+ @if ($dpaAcceptance) + + {{ __('dpa.state_accepted') }} + + @else + + {{ __('dpa.state_open') }} + + @endif +
+ +
+ + {{ __('dpa.read_agreement') }} + + @if ($dpa->measures_path) + + {{ __('dpa.read_measures') }} + + @endif + {{ __('dpa.version', ['version' => $dpa->version]) }} +
+ + @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 +
+ @endif +
diff --git a/routes/admin.php b/routes/admin.php index c236e5e..b4b218c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -59,6 +59,26 @@ Route::get('/mail/preview/{key}', function (string $key) { // into a console page: this is the document itself. return response($mailable->render()); })->name('mail.preview.show'); +// The processing agreement (Art. 28 DSGVO): the document, the version in force, +// and who has accepted it. The TEXT is uploaded, never written here. +Route::get('/processing-agreement', Admin\ProcessingAgreements::class)->name('dpa'); +Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, string $which) { + abort_unless(auth('operator')->user()?->can('dpa.manage') ?? false, 403); + abort_unless(in_array($which, ['agreement', 'measures'], true), 404); + + $version = App\Models\DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + $path = $which === 'agreement' ? $version->agreement_path : $version->measures_path; + + abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); + + // Inline: a contract is read before it is filed, and a download that opens + // nowhere is a download somebody has to go looking for. + return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline', + ]); +})->name('dpa.file'); + // The answers an operator gives over and over, written once — see // Admin\MailTemplates. Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates'); diff --git a/routes/web.php b/routes/web.php index e7e7cfe..1a2436a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -304,6 +304,25 @@ $portal = function () { ); })->name('invoices.pdf'); + // The processing agreement the customer is being asked to accept — the + // version in FORCE, never one named in the address: which document + // applies is ours to say, not a parameter. + Route::get('/processing-agreement/{which}', function (string $which) { + abort_unless(in_array($which, ['agreement', 'measures'], true), 404); + + $version = app(App\Services\Legal\ProcessingAgreement::class)->current(); + $path = $version === null + ? null + : ($which === 'agreement' ? $version->agreement_path : $version->measures_path); + + abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); + + return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline', + ]); + })->name('dpa.file'); + Route::get('/billing', Billing::class)->name('billing'); Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index 77f2c9b..87af37f 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -12,11 +12,11 @@ use Spatie\Permission\Models\Role; it('moves every permission and role to the operator guard, leaving none behind', function () { expect(Permission::where('guard_name', 'web')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(0) - // 17 from the original seed, plus customers.grant_plan and - // instances.restart — the latter created straight onto this guard, - // because since this migration `web` is where a permission goes to - // match nobody at all. - ->and(Permission::where('guard_name', 'operator')->count())->toBe(19) + // 17 from the original seed, plus customers.grant_plan, + // instances.restart and dpa.manage — the later ones created straight + // onto this guard, because since this migration `web` is where a + // permission goes to match nobody at all. + ->and(Permission::where('guard_name', 'operator')->count())->toBe(20) ->and(Role::where('guard_name', 'operator')->count())->toBe(6); }); @@ -157,7 +157,9 @@ it('preflights every customer conflict before mutating anything, listing all of // Nothing mutated: still exactly the pre-migration shape, not "moved and // then rolled back" — there is nothing here to roll back on a real // server, which is the whole reason this has to be checked up front. - expect(Permission::where('guard_name', 'web')->count())->toBe(19) + // Every capability this installation has, pushed back to `web` above to + // stage the pre-migration shape — the figure grows with each new one. + expect(Permission::where('guard_name', 'web')->count())->toBe(20) ->and(Permission::where('guard_name', 'operator')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(6) ->and(Role::where('guard_name', 'operator')->count())->toBe(0) diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php new file mode 100644 index 0000000..e978be3 --- /dev/null +++ b/tests/Feature/ProcessingAgreementTest.php @@ -0,0 +1,216 @@ +create(['email_verified_at' => now()]); + $customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]); + + return [$user, $customer]; +} + +function publishedDpa(string $version = '1.0', bool $withMeasures = true): DpaVersion +{ + Storage::fake('local'); + + return DpaVersion::query()->create([ + 'version' => $version, + 'agreement_path' => UploadedFile::fake()->create('avv.pdf', 12, 'application/pdf')->store('dpa', 'local'), + 'measures_path' => $withMeasures + ? UploadedFile::fake()->create('tom.pdf', 12, 'application/pdf')->store('dpa', 'local') + : null, + 'published_at' => now()->subDay(), + ]); +} + +it('shows the customer nothing at all until a version is in force', function () { + // A card offering an agreement that does not exist is worse than silence. + [$user] = dpaCustomer(); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertDontSee(__('dpa.title')); +}); + +it('offers the document and records the acceptance with its evidence', function () { + $version = publishedDpa(); + [$user, $customer] = dpaCustomer(); + + $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']); + + $page->assertSee(__('dpa.title')) + ->assertSee(__('dpa.state_open')) + ->assertSee(__('dpa.accept_cta')) + ->call('acceptProcessingAgreement') + ->assertDispatched('notify'); + + $acceptance = DpaAcceptance::query()->where('customer_id', $customer->id)->sole(); + + // What makes it evidence rather than a flag: the version, the moment, the + // login that pressed it, and where from. + expect($acceptance->dpa_version_id)->toBe($version->id) + ->and($acceptance->user_id)->toBe($user->id) + ->and($acceptance->accepted_at)->not->toBeNull() + ->and($acceptance->ip)->not->toBeNull(); + + // And the card now states it rather than asking again. + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('dpa.state_accepted')) + ->assertDontSee(__('dpa.accept_cta')); +}); + +it('counts pressing twice as one agreement', function () { + publishedDpa(); + [$user, $customer] = dpaCustomer(); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->call('acceptProcessingAgreement') + ->call('acceptProcessingAgreement'); + + expect(DpaAcceptance::query()->where('customer_id', $customer->id)->count())->toBe(1); +}); + +it('asks again when a new version comes into force, and keeps the old acceptance', function () { + // Acceptance is per version: a new one supersedes the last, and accepting a + // superseded document is not accepting the one in force. The old row stays — + // it was true when it was made, and the history is the point. + $first = publishedDpa('1.0'); + [$user, $customer] = dpaCustomer(); + + app(ProcessingAgreement::class)->accept($customer); + + expect(app(ProcessingAgreement::class)->accepted($customer))->toBeTrue(); + + $second = DpaVersion::query()->create([ + 'version' => '2.0', + 'agreement_path' => $first->agreement_path, + 'published_at' => now(), + ]); + + expect(app(ProcessingAgreement::class)->current()->id)->toBe($second->id) + ->and(app(ProcessingAgreement::class)->accepted($customer))->toBeFalse() + // Not deleted, not overwritten. + ->and(DpaAcceptance::query()->where('dpa_version_id', $first->id)->exists())->toBeTrue(); +}); + +it('serves the document to the customer, and only the one in force', function () { + publishedDpa(); + [$user] = dpaCustomer(); + + $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertOk(); + $this->actingAs($user)->get(route('dpa.file', 'measures'))->assertOk(); + + // Which document applies is ours to say, not a parameter somebody can put + // in the address. + $this->actingAs($user)->get(route('dpa.file', 'sonstwas'))->assertNotFound(); +}); + +it('does not hand the agreement to a stranger', function () { + publishedDpa(); + + $this->get(route('dpa.file', 'agreement'))->assertRedirect(); +}); + +it('answers 404 rather than 500 when nothing is published', function () { + [$user] = dpaCustomer(); + + $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertNotFound(); +}); + +// ---- The console ---- + +it('takes an upload as a draft, and only a second act puts it in force', function () { + // Publishing leaves every customer who accepted the previous version + // outstanding again. That is correct, and expensive to trigger by dropping a + // file on a form. + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '3.0') + ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) + ->call('upload') + ->assertHasNoErrors(); + + $version = DpaVersion::query()->where('version', '3.0')->sole(); + + expect($version->isPublished())->toBeFalse() + ->and(app(ProcessingAgreement::class)->current())->toBeNull() + ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->call('publish', $version->uuid); + + expect(app(ProcessingAgreement::class)->current()?->id)->toBe($version->id); +}); + +it('takes the agreement as a PDF and nothing else', function () { + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '4.0') + ->set('agreement', UploadedFile::fake()->create('avv.docx', 20, 'application/msword')) + ->call('upload') + ->assertHasErrors(['agreement']); +}); + +it('refuses two versions under one name', function () { + publishedDpa('1.0'); + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '1.0') + ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) + ->call('upload') + ->assertHasErrors(['version']); +}); + +it('keeps the whole thing to operators who may manage it', function () { + // Its own capability: whoever keeps the platform running does not thereby + // decide what every customer is asked to agree to. + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(ProcessingAgreements::class) + ->assertForbidden(); + + $this->actingAs(operator('Read-only'), 'operator') + ->get(route('admin.dpa')) + ->assertForbidden(); +}); + +it('confirms publication in a modal rather than on one click', function () { + // R23, and for a reason beyond the rule: this is the click that makes every + // customer outstanding again. + $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/processing-agreements.blade.php')); + + expect($page)->toContain("component: 'admin.confirm-publish-dpa'"); + + $modal = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/ConfirmPublishDpa.php')); + + expect($modal)->toContain("dispatch('dpa-publish-confirmed'") + // The modal decides nothing: the permission check stays where it was. + ->and($modal)->not->toContain('published_at'); +}); From 56daaef7ded253aa34d10975bb42468202fff1ab Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 16:56:58 +0200 Subject: [PATCH 02/12] Release v1.3.58 Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 936003c..351ad2e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.57 +1.3.58 From d04ea712c740a202f8c206c23504405434848273 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:12:30 +0200 Subject: [PATCH 03/12] Write the processing agreement and its annex, and let it be downloaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism shipped without a document. Both are here now, generated from this repository rather than uploaded — so the company is named from CompanyProfile exactly as the invoices name it, the sub-processor list is the three places customer data really leaves this application, and the deletion deadlines are read off the commands that enforce them. A wording change is a diff somebody can review, not a file that appears. `clupilot:publish-dpa 1.0` renders both to PDF (TCPDF, the engine the invoices already use), stores them on the private disk and puts them in force; `--draft` stops short of that. The console's upload form is untouched: a lawyer's revision arrives as a PDF and becomes a version like any other. **The annex says what this installation does, not what it would like to.** Every measure in it was checked against the code first — the isolation is one VM per customer because that is what provisioning builds, the backup is a daily snapshot job at 02:00 because that is what RegisterBackup creates, the deletion deadlines are the two prune commands. Claims the system does not currently keep are NOT in the document, and they are named in the handover instead. A TOM that promises more than the machine does is the document an auditor reads before looking. **Downloading** now carries the version in the filename — CluPilot-AV-Vertrag-1.0.pdf — so "which fassung did I agree to" is answerable from a downloads folder months later without opening anything. Both sides get the button; inline stays the default, because a contract is read before it is filed. Placeholder register data is left off the document rather than printed: a "FN 000000a" on a contract looks like a real number and is not one. Also: the wordmark scan tripped on a paragraph whose first word is the company name, for the second time. It now looks for the name as the whole CONTENT of an element, which is what a lockup is, rather than for the name after a tag, which is also how a sentence starts. Co-Authored-By: Claude Opus 5 --- .../Commands/PublishProcessingAgreement.php | 69 ++++++ app/Services/Legal/DpaRenderer.php | 109 ++++++++++ lang/de/dpa.php | 1 + lang/de/dpa_admin.php | 1 + lang/en/dpa.php | 1 + lang/en/dpa_admin.php | 1 + resources/views/legal/dpa/agreement.blade.php | 204 ++++++++++++++++++ resources/views/legal/dpa/measures.blade.php | 148 +++++++++++++ .../admin/processing-agreements.blade.php | 4 + resources/views/livewire/settings.blade.php | 6 + routes/admin.php | 16 +- routes/web.php | 15 +- tests/Feature/ProcessingAgreementTest.php | 75 ++++++- tests/Feature/SiteDesignSystemTest.php | 18 +- 14 files changed, 650 insertions(+), 18 deletions(-) create mode 100644 app/Console/Commands/PublishProcessingAgreement.php create mode 100644 app/Services/Legal/DpaRenderer.php create mode 100644 resources/views/legal/dpa/agreement.blade.php create mode 100644 resources/views/legal/dpa/measures.blade.php diff --git a/app/Console/Commands/PublishProcessingAgreement.php b/app/Console/Commands/PublishProcessingAgreement.php new file mode 100644 index 0000000..12a6cd4 --- /dev/null +++ b/app/Console/Commands/PublishProcessingAgreement.php @@ -0,0 +1,69 @@ +argument('version'); + + if (DpaVersion::query()->where('version', $version)->exists()) { + $this->error("Version {$version} already exists. Pick another name — two documents under one name makes every acceptance ambiguous."); + + return self::FAILURE; + } + + $agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf'; + $measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf'; + + Storage::disk('local')->put($agreementPath, $renderer->agreement($version)); + Storage::disk('local')->put($measuresPath, $renderer->measures($version)); + + $row = DpaVersion::query()->create([ + '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. + 'published_at' => $this->option('draft') ? null : now(), + ]); + + $this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.'); + $this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)'); + $this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)'); + + return self::SUCCESS; + } + + private function slug(string $version): string + { + return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version'; + } +} diff --git a/app/Services/Legal/DpaRenderer.php b/app/Services/Legal/DpaRenderer.php new file mode 100644 index 0000000..2567f50 --- /dev/null +++ b/app/Services/Legal/DpaRenderer.php @@ -0,0 +1,109 @@ +> + */ + public static function subprocessors(): array + { + return [ + [ + 'name' => 'Hetzner Online GmbH', + 'service' => 'Rechenzentrum, Server und Netzanbindung der Kundeninstanzen', + 'location' => 'Falkenstein und Nürnberg, Deutschland; Helsinki, Finnland', + ], + [ + 'name' => 'Stripe Payments Europe, Ltd.', + 'service' => 'Zahlungsabwicklung (Vertrags- und Zahlungsdaten des Kunden, keine Instanzinhalte)', + 'location' => 'Irland (EU)', + ], + [ + 'name' => 'thinkidoo e.U.', + 'service' => 'Mailserver für den Versand und Empfang der Betriebs- und Support-E-Mails', + 'location' => 'Österreich', + ], + ]; + } + + /** The agreement itself. */ + public function agreement(string $version, ?Carbon $issuedOn = null): string + { + return $this->render('legal.dpa.agreement', $version, $issuedOn, [ + 'subprocessors' => self::subprocessors(), + ]); + } + + /** Annex 1: the technical and organisational measures. */ + public function measures(string $version, ?Carbon $issuedOn = null): string + { + return $this->render('legal.dpa.measures', $version, $issuedOn, [ + // From the commands that enforce them, so the document cannot + // promise one deadline while the sweep runs another. + 'unverifiedDays' => PruneUnverifiedAccounts::AFTER_DAYS, + 'warnDays' => PruneDormantAccounts::WARN_DAYS_BEFORE, + ]); + } + + /** @param array $data */ + private function render(string $view, string $version, ?Carbon $issuedOn, array $data): string + { + $issuedOn ??= now(); + + $html = View::make($view, array_merge($data, [ + 'version' => $version, + // R19: written on the wall clock, because a date on a contract is + // read by a person and not by a server. + 'issuedOn' => $issuedOn->local()->isoFormat('LL'), + ]))->render(); + + $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8'); + $pdf->SetCreator('CluPilot'); + $pdf->SetAuthor((string) (CompanyProfile::get('name') ?? 'CluPilot')); + $pdf->SetTitle(__('dpa.title').' '.$version); + // No default header or footer bar: this is a contract, not a report, and + // TCPDF's default header prints a black line and a title nobody set. + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(true); + $pdf->SetMargins(20, 18, 20); + $pdf->SetAutoPageBreak(true, 20); + // DejaVu, because the text is German: TCPDF's core fonts have no ß in + // the encoding this uses and would print it as a blank. + $pdf->SetFont('dejavusans', '', 9.5); + $pdf->AddPage(); + $pdf->writeHTML($html, true, false, true, false, ''); + + return (string) $pdf->Output('', 'S'); + } +} diff --git a/lang/de/dpa.php b/lang/de/dpa.php index 2760de6..7cd9a97 100644 --- a/lang/de/dpa.php +++ b/lang/de/dpa.php @@ -14,4 +14,5 @@ return [ '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.', + 'download' => 'Herunterladen', ]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php index 4ac6ec3..1711fb0 100644 --- a/lang/de/dpa_admin.php +++ b/lang/de/dpa_admin.php @@ -32,4 +32,5 @@ return [ 'published' => 'Fassung :version ist in Kraft.', 'publish_title' => 'Fassung :version in Kraft setzen?', 'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.', + 'download' => 'Herunterladen', ]; diff --git a/lang/en/dpa.php b/lang/en/dpa.php index c99e0e4..598c85b 100644 --- a/lang/en/dpa.php +++ b/lang/en/dpa.php @@ -14,4 +14,5 @@ return [ '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.', + 'download' => 'Download', ]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php index 5a97e5e..96c1802 100644 --- a/lang/en/dpa_admin.php +++ b/lang/en/dpa_admin.php @@ -32,4 +32,5 @@ return [ 'published' => 'Version :version is in force.', 'publish_title' => 'Put version :version in force?', 'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.', + 'download' => 'Download', ]; diff --git a/resources/views/legal/dpa/agreement.blade.php b/resources/views/legal/dpa/agreement.blade.php new file mode 100644 index 0000000..6ac35ff --- /dev/null +++ b/resources/views/legal/dpa/agreement.blade.php @@ -0,0 +1,204 @@ +{{-- + Auftragsverarbeitungsvertrag (Art. 28 DSGVO). + + Rendered to PDF by App\Services\Legal\DpaRenderer through TCPDF, so the + markup is deliberately plain: headings, paragraphs, lists and simple tables. + TCPDF's HTML support is not a browser's — no flexbox, no grid, no custom + properties — and anything fancier silently renders as something else. + + The company's own details come from CompanyProfile, so this document and the + invoices cannot name the same company differently. + + ## What this text is and is not + + It is a working agreement built from what this installation actually does: + the sub-processors are the ones the software really talks to, the measures + referenced are the ones in the annex, and the deletion deadlines are the ones + the code enforces. It is NOT a lawyer's opinion, and it should be read by one + before it is relied on in a dispute — particularly the liability clause and + anything a customer negotiates. +--}} +@php + $c = App\Support\CompanyProfile::all(); + $seat = trim(($c['postcode'] ?? '').' '.($c['city'] ?? '')); + + // A register number of "FN 000000a" is a placeholder somebody has not + // replaced yet, and printing it on a contract is worse than leaving the line + // out: it looks like a real number and is not one. Anything whose digits are + // all zeros is treated as unfilled. + $real = fn (?string $value) => $value !== null && $value !== '' && preg_match('/[1-9]/', $value) === 1; +@endphp + +

Vertrag über die Auftragsverarbeitung

+

gemäß Art. 28 der Verordnung (EU) 2016/679 (DSGVO)

+ +

Fassung {{ $version }} · Stand {{ $issuedOn }}

+ +

Die Vertragsparteien

+ +

Auftragsverarbeiter (im Folgenden „CluPilot"):
+{{ $c['name'] ?? 'CluPilot Cloud e.U.' }}
+{{ $c['address'] ?? '' }}
+{{ $seat }}, {{ $c['country'] ?? 'Österreich' }}
+@if ($real($c['register_number'] ?? null)){{ $c['register_number'] }}, {{ $c['register_court'] ?? '' }}
@endif +@if ($real($c['vat_id'] ?? null)){{ $c['vat_id'] }}
@endif +{{ $c['email'] ?? '' }}

+ +

Verantwortlicher (im Folgenden „Kunde"):
+die im Kundenkonto hinterlegten Stammdaten. Der Kunde hält diese Angaben aktuell; sie +sind Bestandteil dieses Vertrags.

+ +

Dieser Vertrag wird elektronisch geschlossen. Art. 28 Abs. 9 DSGVO lässt das +ausdrücklich zu; eine Unterschrift auf Papier ist nicht erforderlich. CluPilot hält +Fassung, Zeitpunkt und IP-Adresse des Abschlusses fest und stellt dem Kunden den +Vertrag im Kundenbereich dauerhaft zum Abruf bereit.

+ +

1. Gegenstand und Dauer

+ +

1.1 CluPilot betreibt für den Kunden eine eigene Nextcloud-Instanz und verarbeitet +dabei personenbezogene Daten ausschließlich im Auftrag und nach Weisung des Kunden. +Der Kunde bleibt Verantwortlicher im Sinne von Art. 4 Z 7 DSGVO.

+ +

1.2 Dieser Vertrag gilt für die Dauer des Hauptvertrags (Nutzung eines CluPilot-Pakets) +und endet mit ihm. Regelungen, die ihrer Natur nach fortwirken — insbesondere +Verschwiegenheit und Löschung —, gelten darüber hinaus.

+ +

2. Art, Zweck und Umfang der Verarbeitung

+ +

2.1 Zweck: Bereitstellung, Betrieb, Wartung und Sicherung einer +Nextcloud-Installation samt zugehöriger Dienste (Speicherung und Freigabe von Dateien, +Kalender, Kontakte, sowie die vom Kunden gebuchten Zusatzmodule).

+ +

2.2 Art der Verarbeitung: Speichern, Aufbewahren, Sichern, Übermitteln im +Rahmen des Dienstes, Löschen. Eine inhaltliche Auswertung der Kundendaten findet nicht +statt.

+ +

2.3 Kategorien betroffener Personen: Beschäftigte, Kundinnen und Kunden, +Mandantinnen und Mandanten, Patientinnen und Patienten, Lieferanten und sonstige +Kontakte des Kunden — je nachdem, wessen Daten der Kunde in seiner Instanz speichert.

+ +

2.4 Kategorien personenbezogener Daten: Stamm- und Kontaktdaten, +Kommunikationsdaten, Inhalts- und Dokumentendaten, Nutzungs- und Protokolldaten der +Instanz. Der Kunde entscheidet allein, welche Daten er speichert; besondere Kategorien +nach Art. 9 DSGVO sind möglich, wenn der Kunde solche Daten einbringt.

+ +

3. Weisungen

+ +

3.1 CluPilot verarbeitet die Daten nur auf dokumentierte Weisung des Kunden. Der +Hauptvertrag samt Leistungsbeschreibung ist die vollständige Weisung für den +Regelbetrieb; weitere Weisungen erteilt der Kunde in Textform an +{{ $c['email'] ?? '' }} oder über den Kundenbereich.

+ +

3.2 Hält CluPilot eine Weisung für rechtswidrig, teilt sie dies dem Kunden unverzüglich +mit und darf die Ausführung bis zur Bestätigung aussetzen (Art. 28 Abs. 3 letzter Satz +DSGVO).

+ +

3.3 Eine Verarbeitung zu eigenen Zwecken findet nicht statt. Davon unberührt bleiben +Daten, die CluPilot als eigener Verantwortlicher verarbeitet — insbesondere die +Vertrags- und Rechnungsdaten des Kunden selbst.

+ +

4. Vertraulichkeit

+ +

CluPilot setzt zur Verarbeitung nur Personen ein, die zur Vertraulichkeit verpflichtet +und über die einschlägigen Bestimmungen des Datenschutzes unterwiesen sind (Art. 28 +Abs. 3 lit. b DSGVO). Die Verpflichtung wirkt über das Ende der Tätigkeit hinaus.

+ +

5. Technische und organisatorische Maßnahmen

+ +

5.1 CluPilot trifft die Maßnahmen nach Art. 32 DSGVO. Sie sind in der Anlage 1 +(TOM) beschrieben, die Bestandteil dieses Vertrags ist.

+ +

5.2 Die Maßnahmen unterliegen dem technischen Fortschritt. CluPilot darf sie +weiterentwickeln, solange das Schutzniveau nicht unterschritten wird; wesentliche +Änderungen werden dem Kunden mit einer neuen Fassung dieses Vertrags mitgeteilt.

+ +

6. Unterauftragsverarbeiter

+ +

6.1 Der Kunde erteilt die allgemeine Genehmigung zur Beauftragung der nachstehenden +Unterauftragsverarbeiter (Art. 28 Abs. 4 DSGVO):

+ + + + + + + + @foreach ($subprocessors as $sub) + + + + + + @endforeach +
UnternehmenLeistungOrt der Verarbeitung
{{ $sub['name'] }}{{ $sub['service'] }}{{ $sub['location'] }}
+ +

6.2 CluPilot informiert den Kunden über die Hinzuziehung oder den Austausch eines +Unterauftragsverarbeiters mindestens vier Wochen vorher in Textform. Der Kunde kann +binnen zwei Wochen ab Zugang aus wichtigem, datenschutzbezogenem Grund widersprechen; +in diesem Fall kann jede Seite den Vertrag zum Wirksamwerden der Änderung kündigen.

+ +

6.3 CluPilot verpflichtet jeden Unterauftragsverarbeiter auf Pflichten, die diesem +Vertrag entsprechen, und haftet für dessen Verhalten wie für eigenes.

+ +

7. Ort der Verarbeitung, Drittländer

+ +

Die Verarbeitung findet ausschließlich in Mitgliedstaaten der Europäischen Union +statt. Eine Übermittlung in ein Drittland erfolgt nicht; sollte sie künftig +erforderlich werden, geschieht dies nur unter den Voraussetzungen der Art. 44 ff. +DSGVO und nach vorheriger Information des Kunden.

+ +

8. Unterstützung des Kunden

+ +

8.1 CluPilot unterstützt den Kunden mit geeigneten Maßnahmen bei der Erfüllung von +Betroffenenrechten (Art. 12 bis 23 DSGVO). Wendet sich eine betroffene Person direkt an +CluPilot, wird sie an den Kunden verwiesen und der Kunde unverzüglich verständigt.

+ +

8.2 CluPilot unterstützt den Kunden bei den Pflichten nach Art. 32 bis 36 DSGVO, +insbesondere bei Datenschutz-Folgenabschätzungen und bei Meldungen an die Aufsichtsbehörde.

+ +

8.3 Verletzungen des Schutzes personenbezogener Daten meldet CluPilot dem Kunden +unverzüglich, spätestens innerhalb von 24 Stunden ab Kenntnis, mit den nach Art. 33 +Abs. 3 DSGVO erforderlichen Angaben, soweit sie vorliegen. Die Meldung an die +Aufsichtsbehörde obliegt dem Kunden.

+ +

9. Nachweise und Kontrollen

+ +

9.1 CluPilot stellt dem Kunden auf Anfrage die zum Nachweis der Einhaltung dieses +Vertrags erforderlichen Informationen zur Verfügung, insbesondere die jeweils geltende +Fassung der Anlage 1.

+ +

9.2 Der Kunde ist berechtigt, sich von der Einhaltung zu überzeugen. Kontrollen finden +nach Anmeldung mit angemessener Frist, während der üblichen Geschäftszeiten und ohne +Störung des Betriebsablaufs statt. CluPilot kann eine Kontrolle durch Vorlage geeigneter +Nachweise ersetzen, soweit dies zur Überprüfung ausreicht. Der Zutritt zu Räumen Dritter +richtet sich nach den Regeln des jeweiligen Unterauftragsverarbeiters.

+ +

10. Löschung und Rückgabe

+ +

10.1 Nach Beendigung des Hauptvertrags stellt CluPilot dem Kunden dessen Daten in +einem gängigen Format zum Abruf bereit. Die Bereitstellung erfolgt zum Ende der bezahlten +Periode; der Kunde hat ab Bereitstellung 30 Tage Zeit, sie abzuholen.

+ +

10.2 Nach Ablauf dieser Frist löscht CluPilot die Instanz samt aller darin enthaltenen +personenbezogenen Daten sowie die zugehörigen Sicherungen. Die Löschung wird dem Kunden +auf Anfrage bestätigt.

+ +

10.3 Von der Löschung ausgenommen sind Daten, für die eine gesetzliche +Aufbewahrungspflicht besteht — insbesondere Rechnungen und Buchhaltungsunterlagen +(sieben Jahre, § 132 BAO). Diese Daten verarbeitet CluPilot als eigener Verantwortlicher.

+ +

11. Haftung

+ +

Es gilt Art. 82 DSGVO. Im Übrigen richtet sich die Haftung nach dem Hauptvertrag.

+ +

12. Schlussbestimmungen

+ +

12.1 Änderungen bedürfen der Textform. Widerspricht dieser Vertrag dem Hauptvertrag in +Fragen des Datenschutzes, geht dieser Vertrag vor.

+ +

12.2 Ist eine Bestimmung unwirksam, bleibt der übrige Vertrag gültig.

+ +

12.3 Es gilt österreichisches Recht. Gerichtsstand ist, soweit gesetzlich zulässig, +{{ $c['city'] ?? 'Wien' }}.

+ +

Anlage 1: Technische und organisatorische Maßnahmen (TOM), Fassung {{ $version }}.

diff --git a/resources/views/legal/dpa/measures.blade.php b/resources/views/legal/dpa/measures.blade.php new file mode 100644 index 0000000..7ab10ce --- /dev/null +++ b/resources/views/legal/dpa/measures.blade.php @@ -0,0 +1,148 @@ +{{-- + Anlage 1: Technische und organisatorische Maßnahmen (Art. 32 DSGVO). + + Written from what this installation ACTUALLY does — the backup job as + RegisterBackup creates it, the isolation Proxmox gives, the sign-in as + Fortify enforces it, the deletion deadlines the two prune commands run. Every + sentence here is meant to survive somebody checking it against the machine. + + Where a measure is planned but not in place, it is not written down. A TOM + that claims more than the system does is worse than a short one: it is the + document an auditor reads before looking. +--}} +@php + $c = App\Support\CompanyProfile::all(); +@endphp + +

Anlage 1 — Technische und organisatorische Maßnahmen

+

gemäß Art. 32 DSGVO, Anlage zum Auftragsverarbeitungsvertrag

+ +

Fassung {{ $version }} · Stand {{ $issuedOn }} · {{ $c['name'] ?? 'CluPilot Cloud e.U.' }}

+ +

Diese Anlage beschreibt die Maßnahmen, die CluPilot beim Betrieb der +Kundeninstanzen tatsächlich umsetzt. Sie wird mit jeder Fassung des +Auftragsverarbeitungsvertrags überprüft und, wo nötig, fortgeschrieben.

+ +

1. Zutrittskontrolle (physisch)

+ +

Die Instanzen laufen ausschließlich in Rechenzentren der in Ziffer 6 des +Hauptvertrags genannten Unterauftragsverarbeiter innerhalb der Europäischen Union. +Physischer Zutritt, Videoüberwachung, Zutrittsprotokollierung, Brand- und +Einbruchschutz sowie unterbrechungsfreie Stromversorgung liegen in deren +Verantwortung und richten sich nach deren jeweils veröffentlichten Nachweisen +(u. a. ISO/IEC 27001). CluPilot selbst betreibt keine eigenen Serverräume.

+ +

2. Zugangskontrolle (Systemzugang)

+ +
    +
  • Jeder Zugang zum Kundenportal und zur Betreiberkonsole erfordert Benutzername + und Passwort. Passwörter werden ausschließlich als Hash gespeichert (bcrypt); + die Klartexte sind CluPilot zu keinem Zeitpunkt bekannt.
  • +
  • Zwei-Faktor-Authentisierung (TOTP) steht sowohl Kundinnen und Kunden als auch + Betreibern zur Verfügung; für Betreiberkonten wird sie eingesetzt.
  • +
  • Sicherheitsrelevante Änderungen (Zwei-Faktor abschalten, Schlüssel speichern) + verlangen zusätzlich eine erneute Passwortbestätigung innerhalb der laufenden + Sitzung.
  • +
  • Angemeldete Geräte werden je Konto geführt und können einzeln abgemeldet + werden; eine Anmeldung von einem neuen Gerät löst eine Benachrichtigung per + E-Mail aus.
  • +
  • Die Betreiberkonsole ist über eine eigene Adresse erreichbar und für das + öffentliche Internet gesperrt; der Zugang erfolgt über ein privates Netz + (WireGuard).
  • +
  • Der administrative Zugriff auf die Virtualisierungsplattform erfolgt über + API-Token je Host, nicht über gemeinsam genutzte Kennwörter.
  • +
+ +

3. Zugriffskontrolle (Berechtigungen)

+ +
    +
  • Die Betreiberkonsole kennt abgestufte Rollen (Owner, Admin, Support, Billing, + Read-only) mit einzeln vergebenen Berechtigungen; jede Aktion prüft die + Berechtigung serverseitig, nicht durch Ausblenden von Bedienelementen.
  • +
  • Betreiber- und Kundenkonten sind getrennte Identitäten in getrennten Tabellen + mit getrennten Anmeldewegen. Ein Kundenkonto kann keine Betreiberrechte + erhalten.
  • +
  • CluPilot greift nicht auf Inhalte in Kundeninstanzen zu. Ein Zugriff erfolgt + nur, wenn der Kunde im Rahmen einer Supportanfrage darum ersucht, oder wenn + eine gesetzliche Verpflichtung besteht.
  • +
  • Zugangsdaten und Schlüssel (Zahlungsdienst, DNS, Hosts, Mailkonten) werden + verschlüsselt gespeichert; der dafür verwendete Schlüssel ist vom + Anwendungsschlüssel getrennt.
  • +
+ +

4. Trennungskontrolle (Mandantentrennung)

+ +
    +
  • Jede Kundin und jeder Kunde erhält eine eigene virtuelle Maschine mit + eigenem Betriebssystem, eigener Datenbank und eigenem Dateisystem. Es gibt + keine geteilte Nextcloud-Installation und keine gemeinsame Datenbank für + Kundeninhalte.
  • +
  • Jede Instanz hat eine eigene Adresse und ein eigenes Zertifikat.
  • +
  • Die Verwaltungsdaten von CluPilot (Verträge, Rechnungen, Betriebsdaten) sind + von den Kundeninhalten getrennt und liegen nicht in den Kundeninstanzen.
  • +
+ +

5. Weitergabekontrolle (Transport und Ablage)

+ +
    +
  • Sämtlicher Verkehr zu Kundeninstanzen, Portal und Konsole läuft über HTTPS mit + Zertifikaten von Let's Encrypt; unverschlüsselte Verbindungen werden nicht + angeboten.
  • +
  • E-Mail wird über TLS an den Mailserver übergeben.
  • +
  • Administrativer Zugriff auf Hosts erfolgt über SSH beziehungsweise über die + API der Virtualisierungsplattform, jeweils verschlüsselt.
  • +
  • Zahlungsdaten werden nicht von CluPilot verarbeitet: Karteneingabe und + Zahlungsabwicklung finden beim Zahlungsdienstleister statt. CluPilot erfährt + nur, dass und in welcher Höhe gezahlt wurde.
  • +
+ +

6. Eingabekontrolle (Nachvollziehbarkeit)

+ +
    +
  • Jede automatisierte Einrichtung, Änderung und Beendigung einer Instanz wird + Schritt für Schritt protokolliert und bleibt in der Konsole einsehbar.
  • +
  • Ausgehende E-Mails an Kundinnen und Kunden werden mit Zeitpunkt, Empfänger und + Betreff in einem eigenen Register geführt.
  • +
  • Wesentliche Vertragsvorgänge (Bestellung, Kündigung samt Grund, Widerruf, + Abschluss dieses Vertrags) werden mit Zeitpunkt festgehalten; der Abschluss + dieser Vereinbarung zusätzlich mit Fassung und IP-Adresse.
  • +
  • Anwendungs- und Systemprotokolle werden auf den Servern geführt.
  • +
+ +

7. Verfügbarkeit und Belastbarkeit

+ +
    +
  • Sicherung: Für jede Instanz wird bei der Einrichtung ein täglicher + Sicherungslauf um 02:00 Uhr auf der Virtualisierungsplattform eingerichtet + (Snapshot der gesamten Maschine).
  • +
  • Überwachung: Erreichbarkeit und Zustand der Instanzen und Hosts werden + laufend überwacht; Störungen erzeugen Meldungen an den Betrieb.
  • +
  • Kapazität: Vor jeder Einrichtung wird geprüft, ob auf dem Zielhost + ausreichend Speicher und Rechenleistung frei sind; reicht sie nicht, wird die + Bestellung vorgemerkt statt einen Host zu überbuchen.
  • +
  • Aktualisierung: Sicherheits- und Versionsaktualisierungen werden + eingespielt; geplante Wartungen werden angekündigt.
  • +
+ +

8. Löschung und Aufbewahrung

+ +
    +
  • Nach Vertragsende werden die Daten zum Abruf bereitgestellt und nach Ablauf der + im Hauptvertrag genannten Frist samt Sicherungen gelöscht.
  • +
  • Registrierungen, deren E-Mail-Adresse nie bestätigt wurde, werden nach + {{ $unverifiedDays }} Tagen automatisch gelöscht.
  • +
  • Bestätigte Konten ohne jemals gebuchtes Paket werden nach einem Jahr gelöscht; + die Löschung wird {{ $warnDays }} Tage vorher per E-Mail angekündigt.
  • +
  • Rechnungen und Buchhaltungsunterlagen werden sieben Jahre aufbewahrt + (§ 132 BAO). Sie enthalten Vertrags-, nicht Inhaltsdaten.
  • +
+ +

9. Auftragskontrolle und Überprüfung

+ +
    +
  • Unterauftragsverarbeiter werden vertraglich auf ein entsprechendes + Schutzniveau verpflichtet; die Liste ist Teil des Hauptvertrags.
  • +
  • Diese Maßnahmen werden bei jeder neuen Fassung des + Auftragsverarbeitungsvertrags überprüft, mindestens jedoch jährlich.
  • +
  • Ansprechstelle für Datenschutzfragen: {{ $c['email'] ?? '' }}
  • +
diff --git a/resources/views/livewire/admin/processing-agreements.blade.php b/resources/views/livewire/admin/processing-agreements.blade.php index e52bad8..26d86cd 100644 --- a/resources/views/livewire/admin/processing-agreements.blade.php +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -129,6 +129,10 @@ {{ __('dpa_admin.file_measures') }} @endif + + {{ __('dpa_admin.download') }} +
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 05242c8..6ae2ea9 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -376,6 +376,12 @@ {{ __('dpa.read_measures') }} @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. --}} + + {{ __('dpa.download') }} + {{ __('dpa.version', ['version' => $dpa->version]) }}
diff --git a/routes/admin.php b/routes/admin.php index b4b218c..4d0049d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -71,12 +71,16 @@ Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, strin abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); - // Inline: a contract is read before it is filed, and a download that opens - // nowhere is a download somebody has to go looking for. - return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline', - ]); + // Inline: a contract is read before it is filed. With ?download=1 it is + // kept instead, under a name carrying the version. + $name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf'; + + return request()->boolean('download') + ? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name) + : Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$name.'"', + ]); })->name('dpa.file'); // The answers an operator gives over and over, written once — see diff --git a/routes/web.php b/routes/web.php index 1a2436a..006570e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -317,10 +317,17 @@ $portal = function () { abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); - return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline', - ]); + // Inline to read, attachment to keep — and the file that lands in + // somebody's downloads folder carries the VERSION in its name, so + // "which one did I agree to" is answerable from the filename alone. + $name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf'; + + return request()->boolean('download') + ? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name) + : Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$name.'"', + ]); })->name('dpa.file'); Route::get('/billing', Billing::class)->name('billing'); diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php index e978be3..c102d98 100644 --- a/tests/Feature/ProcessingAgreementTest.php +++ b/tests/Feature/ProcessingAgreementTest.php @@ -21,7 +21,9 @@ use Livewire\Livewire; * paper. The website already promises "AV-Vertrag inklusive", which means it has * to be obtainable without asking for it. * - * The TEXT is never this application's. It is uploaded. + * The TEXT comes from one of two places, and the console does not care which: + * generated from this repository (resources/views/legal/dpa, via + * clupilot:publish-dpa) or uploaded as a lawyer's revision. */ function dpaCustomer(): array { @@ -214,3 +216,74 @@ it('confirms publication in a modal rather than on one click', function () { // The modal decides nothing: the permission check stays where it was. ->and($modal)->not->toContain('published_at'); }); + +// ---- The documents this repository generates ---- + +it('renders both documents as real PDFs, from the company on record', function () { + // Generated rather than uploaded, so the agreement cannot name the company + // differently from the invoices — and a wording change is a diff somebody + // can review. + App\Support\CompanyProfile::put(['name' => 'Prüf GmbH', 'city' => 'Wien', 'country' => 'Österreich']); + + $renderer = app(App\Services\Legal\DpaRenderer::class); + + $agreement = $renderer->agreement('9.9'); + $measures = $renderer->measures('9.9'); + + expect(substr($agreement, 0, 4))->toBe('%PDF') + ->and(substr($measures, 0, 4))->toBe('%PDF') + ->and(strlen($agreement))->toBeGreaterThan(20000) + ->and(strlen($measures))->toBeGreaterThan(20000); +}); + +it('names every place customer data actually leaves this application', function () { + // A sub-processor list that quietly omits one is the single most common + // defect in a processing agreement — hosting, payment and mail are the three + // here, and each is named with where it processes. + $names = collect(App\Services\Legal\DpaRenderer::subprocessors())->pluck('name')->implode(' '); + + expect($names)->toContain('Hetzner') + ->and($names)->toContain('Stripe') + ->and(App\Services\Legal\DpaRenderer::subprocessors())->each->toHaveKeys(['name', 'service', 'location']); +}); + +it('publishes a generated version in one command, and refuses to reuse a name', function () { + Storage::fake('local'); + + $this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertSuccessful(); + + $version = DpaVersion::query()->where('version', '7.0')->sole(); + + expect($version->isPublished())->toBeTrue() + ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue() + ->and(Storage::disk('local')->exists($version->measures_path))->toBeTrue(); + + // Two documents under one name makes every acceptance ambiguous. + $this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertFailed(); +}); + +it('keeps a drafted version out of force', function () { + Storage::fake('local'); + + $this->artisan('clupilot:publish-dpa', ['version' => '8.0', '--draft' => true])->assertSuccessful(); + + expect(DpaVersion::query()->where('version', '8.0')->sole()->isPublished())->toBeFalse() + ->and(app(ProcessingAgreement::class)->current())->toBeNull(); +}); + +it('hands the file over under a name carrying the version', function () { + // "Which fassung did I agree to" has to be answerable from a downloads + // folder months later, without opening anything. + publishedDpa('2.5'); + [$user] = dpaCustomer(); + + $this->actingAs($user) + ->get(route('dpa.file', ['which' => 'agreement', 'download' => 1])) + ->assertOk() + ->assertDownload('CluPilot-AV-Vertrag-2.5.pdf'); + + $this->actingAs($user) + ->get(route('dpa.file', ['which' => 'measures', 'download' => 1])) + ->assertOk() + ->assertDownload('CluPilot-TOM-2.5.pdf'); +}); diff --git a/tests/Feature/SiteDesignSystemTest.php b/tests/Feature/SiteDesignSystemTest.php index 1e25482..bb04acd 100644 --- a/tests/Feature/SiteDesignSystemTest.php +++ b/tests/Feature/SiteDesignSystemTest.php @@ -148,13 +148,17 @@ it('draws the wordmark from one definition, not five', function () { $source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname())); - // The wordmark is markup around the name; a plain sentence mentioning - // the company is not. `>Clu` with NO whitespace after the tag catches - // exactly the lockup forms — the name is the whole content of its - // element there. Whitespace used to be allowed, and then the terms page - // arrived with a paragraph whose first word is the company name, which - // is a sentence and not a brand lockup. - if (preg_match('/>Clu(Pilot|<)/', $source)) { + // The wordmark is markup AROUND the name: the name is the whole content + // of its element, so what follows it is the next tag and not more + // sentence. `>Clu<` is the split form, `>CluPilot` up to a tag is the + // whole one. + // + // Twice now a document has tripped a looser version of this — the terms + // page and then the processing agreement, both with a paragraph whose + // first word happens to be the company name. That is a sentence, not a + // brand lockup, and a scan that cannot tell them apart is a scan that + // gets edited every time somebody writes prose. + if (preg_match('/>Clu(<|Pilot\s*<)/', $source)) { $offenders[] = str_replace(resource_path().'/', '', $file->getPathname()); } } From 3b0c6b19b997bb1d6a36984d2bf50c5d6d85529c Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:12:30 +0200 Subject: [PATCH 04/12] Release v1.3.59 Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 351ad2e..fdcd67a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.58 +1.3.59 From 52aacddc56025b0671cd1a62ea59f3dbb82d98a9 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:19:39 +0200 Subject: [PATCH 05/12] Store the agreement where the web process can actually read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every link on the agreement page answered 404 — the download visibly, the two inline ones just as dead — and nothing anywhere said why. The files were there, whole and correct. An artisan run inside a container is root; PHP-FPM is www-data. Laravel's local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out as root-only. The web process could not enter it, `Storage::exists()` answered false, and the routes did exactly what they were told: abort 404. No exception, no log line, a page full of links to nothing. Both writers — the command and the console's upload form — now store with "public" visibility, which is the file MODE and nothing to do with the web: 0755/0644 instead of 0700/0600. The disk's root is outside the document root either way, and the two routes still check who is asking. The command also warns if a file it just wrote is not readable, because the alternative is finding out from a customer. The download itself now looks like one: the `download` attribute on both anchors, and in the console a bordered control rather than a third link in a row of two. Co-Authored-By: Claude Opus 5 --- .../Commands/PublishProcessingAgreement.php | 21 ++++++++++++-- app/Livewire/Admin/ProcessingAgreements.php | 16 +++++++--- .../admin/processing-agreements.blade.php | 8 +++-- resources/views/livewire/settings.blade.php | 2 +- tests/Feature/ProcessingAgreementTest.php | 29 +++++++++++++++++++ 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/PublishProcessingAgreement.php b/app/Console/Commands/PublishProcessingAgreement.php index 12a6cd4..1e69d9f 100644 --- a/app/Console/Commands/PublishProcessingAgreement.php +++ b/app/Console/Commands/PublishProcessingAgreement.php @@ -41,8 +41,19 @@ class PublishProcessingAgreement extends Command $agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf'; $measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf'; - Storage::disk('local')->put($agreementPath, $renderer->agreement($version)); - Storage::disk('local')->put($measuresPath, $renderer->measures($version)); + // Visibility "public" is about the FILE MODE, not about the web: this + // disk's root is outside the document root and nothing here becomes + // reachable by URL. What it changes is 0755/0644 instead of 0700/0600 — + // and that is the difference between a document the web process can read + // and one it cannot. + // + // It matters because this command is normally run as root (an artisan + // call in a container is root; PHP-FPM is www-data). The first run left + // a directory only root could enter, the route's exists() check answered + // false, and every link on the page 404'd with nothing in any log to say + // why. + Storage::disk('local')->put($agreementPath, $renderer->agreement($version), 'public'); + Storage::disk('local')->put($measuresPath, $renderer->measures($version), 'public'); $row = DpaVersion::query()->create([ 'version' => $version, @@ -55,6 +66,12 @@ class PublishProcessingAgreement extends Command 'published_at' => $this->option('draft') ? null : now(), ]); + foreach ([$agreementPath, $measuresPath] as $path) { + if (Storage::disk('local')->getVisibility($path) !== 'public') { + $this->warn("{$path} is not readable by the web process. Run this as the user PHP runs as, or fix the mode — the page will 404 otherwise."); + } + } + $this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.'); $this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)'); $this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)'); diff --git a/app/Livewire/Admin/ProcessingAgreements.php b/app/Livewire/Admin/ProcessingAgreements.php index a3fb285..6a35497 100644 --- a/app/Livewire/Admin/ProcessingAgreements.php +++ b/app/Livewire/Admin/ProcessingAgreements.php @@ -62,13 +62,21 @@ class ProcessingAgreements extends Component 'measures' => 'nullable|file|mimes:pdf|max:10240', ]); - // The private disk. An agreement is not a public asset, and a guessable - // URL to one would be a list of who our customers are. + // The private DISK. An agreement is not a public asset, and a guessable + // URL to one would be a list of who our customers are — the root of this + // disk is outside the document root and nothing here is reachable except + // through the two routes that check who is asking. + // + // The visibility below is the file MODE, not the web: 0755/0644 rather + // than 0700/0600, so a document written by one process (an artisan run + // as root) stays readable by the other (PHP-FPM as www-data). Getting + // that wrong produced a directory nothing could enter and a page whose + // every link 404'd silently. $version = DpaVersion::query()->create([ 'version' => $data['version'], 'note' => $data['note'] ?: null, - 'agreement_path' => $this->agreement->store('dpa', 'local'), - 'measures_path' => $this->measures?->store('dpa', 'local'), + 'agreement_path' => $this->agreement->store('dpa', 'local', 'public'), + 'measures_path' => $this->measures?->store('dpa', 'local', 'public'), 'created_by' => Auth::guard('operator')->id(), ]); diff --git a/resources/views/livewire/admin/processing-agreements.blade.php b/resources/views/livewire/admin/processing-agreements.blade.php index 26d86cd..ce81e8a 100644 --- a/resources/views/livewire/admin/processing-agreements.blade.php +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -129,9 +129,13 @@ {{ __('dpa_admin.file_measures') }} @endif + {{-- A download, so it says so and looks like + a control rather than a third link in + a row of two. --}} - {{ __('dpa_admin.download') }} + download + class="inline-flex items-center gap-1 rounded border border-line-strong bg-surface px-2 py-0.5 text-xs font-semibold text-body hover:bg-surface-hover"> + {{ __('dpa_admin.download') }}
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 6ae2ea9..48b924c 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -379,7 +379,7 @@ {{-- 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. --}} - + {{ __('dpa.download') }} {{ __('dpa.version', ['version' => $dpa->version]) }} diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php index c102d98..d01c630 100644 --- a/tests/Feature/ProcessingAgreementTest.php +++ b/tests/Feature/ProcessingAgreementTest.php @@ -287,3 +287,32 @@ it('hands the file over under a name carrying the version', function () { ->assertOk() ->assertDownload('CluPilot-TOM-2.5.pdf'); }); + +it('leaves the documents readable by the process that has to serve them', function () { + // The failure this prevents had no error message anywhere: an artisan run in + // a container is root, PHP-FPM is www-data, and Laravel's local disk creates + // a private directory as 0700. The web process could not enter it, the + // route's exists() check answered false, and every link on the page 404'd + // while the file sat there perfectly intact. + // + // "public" here is the file MODE and nothing to do with the web: this disk's + // root is outside the document root either way. + Storage::fake('local'); + + $this->artisan('clupilot:publish-dpa', ['version' => '6.1'])->assertSuccessful(); + + $version = DpaVersion::query()->where('version', '6.1')->sole(); + + expect(Storage::disk('local')->getVisibility($version->agreement_path))->toBe('public') + ->and(Storage::disk('local')->getVisibility($version->measures_path))->toBe('public'); +}); + +it('serves the console download too, not only the portal one', function () { + publishedDpa('3.3'); + $version = DpaVersion::query()->where('version', '3.3')->sole(); + + $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.dpa.file', ['uuid' => $version->uuid, 'which' => 'agreement', 'download' => 1])) + ->assertOk() + ->assertDownload('CluPilot-AV-Vertrag-3.3.pdf'); +}); From 976637afb207e8e9a7ac6afc9cfdb300b2fa43a3 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:19:39 +0200 Subject: [PATCH 06/12] Release v1.3.60 Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index fdcd67a..de61442 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.59 +1.3.60 From 18193a731d6686ef43bff5b87a8567d70ca26663 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:30:55 +0200 Subject: [PATCH 07/12] 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'); +}); From f25a0030f08fb0ae0defa5fbe5fbe1822bc134bd Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:30:55 +0200 Subject: [PATCH 08/12] Release v1.3.61 Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index de61442..160c739 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.60 +1.3.61 From 2a97d76b2dad344b22cbdef5c0c3541e3d7629a2 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:38:57 +0200 Subject: [PATCH 09/12] Rebuild the settings page out of panels and rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The withdrawal was invisible in two different ways.** It rendered only while the fourteen days were still running, so the moment they expired the whole block vanished — indistinguishable from a feature nobody built. And the account it was looked for on has no contract at all: no package, no subscription, nothing to withdraw from, so there was never anything for it to be about. It is now a row for every CONSUMER who has a contract, open or not: while it runs, the deadline and the button; afterwards, the sentence saying why it is over. A business never sees it — WithdrawalRight answers that, and Settings::withdraw() refuses them again on the server. And an account with no package says so and offers the packages, instead of a dead sentence in a box. **The page is built out of two pieces now.** x-ui.panel is a group: a card with a header that names it. x-ui.row is a setting inside it: what it is on the left, the control on the right, dividers between rows. That is the shape every settings page worth copying uses, and it is the shape that uses the width — a 280px label column with the control beside it fills the line, where a stack of full-width inputs leaves two thirds of it empty and a grid of cards of different heights walks down the screen as a staircase. All four tabs are rebuilt on it, so the page reads as one designed thing rather than four pages that happen to share a tab bar. Below `sm` the rows stack, because on a telephone a label belongs above its field. Co-Authored-By: Claude Opus 5 --- lang/de/settings.php | 3 + lang/en/settings.php | 3 + resources/views/components/ui/panel.blade.php | 40 ++ resources/views/components/ui/row.blade.php | 27 + resources/views/livewire/settings.blade.php | 679 +++++++++--------- tests/Feature/SettingsTest.php | 68 ++ 6 files changed, 480 insertions(+), 340 deletions(-) create mode 100644 resources/views/components/ui/panel.blade.php create mode 100644 resources/views/components/ui/row.blade.php diff --git a/lang/de/settings.php b/lang/de/settings.php index fc58f40..098d932 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -49,6 +49,9 @@ return [ 'brand_using_default' => 'Es werden aktuell die CluPilot-Standardwerte verwendet.', 'branding_saved' => 'Branding gespeichert.', + 'contract_title' => 'Vertrag und Konto', + 'contract_sub' => 'Ihr Paket, Ihre Rechte daraus und die Unterlagen dazu.', + 'to_packages' => 'Pakete ansehen', 'package_title' => 'Paket', 'package_active' => 'Aktives Paket: :plan.', 'no_package' => 'Kein aktives Paket.', diff --git a/lang/en/settings.php b/lang/en/settings.php index 158611a..e72e94f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -49,6 +49,9 @@ return [ 'brand_using_default' => 'Currently using the CluPilot defaults.', 'branding_saved' => 'Branding saved.', + 'contract_title' => 'Contract and account', + 'contract_sub' => 'Your package, the rights that come with it, and the documents.', + 'to_packages' => 'See the packages', 'package_title' => 'Package', 'package_active' => 'Active package: :plan.', 'no_package' => 'No active package.', diff --git a/resources/views/components/ui/panel.blade.php b/resources/views/components/ui/panel.blade.php new file mode 100644 index 0000000..3b2740b --- /dev/null +++ b/resources/views/components/ui/panel.blade.php @@ -0,0 +1,40 @@ +@props([ + 'title' => null, + 'subtitle' => null, +]) + +{{-- A settings panel: one card, a header that names it, a body of rows, and an + optional footer for whatever finishes the group. + + The page was a heap of cards of different heights, each holding one thing, + which produced a staircase down the screen and left half the width empty + beside it. A panel with divided rows is what every settings page worth + copying does — the group is the card, the settings are its rows, and the + rhythm comes from the dividers rather than from the gaps between boxes. --}} +
merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface shadow-xs']) }}> + @if ($title || isset($header)) +
+ @isset($header) + {{ $header }} + @else +
+

{{ $title }}

+ @if ($subtitle) +

{{ $subtitle }}

+ @endif +
+ @isset($action) +
{{ $action }}
+ @endisset + @endisset +
+ @endif + +
{{ $slot }}
+ + @isset($footer) +
+ {{ $footer }} +
+ @endisset +
diff --git a/resources/views/components/ui/row.blade.php b/resources/views/components/ui/row.blade.php new file mode 100644 index 0000000..f0e6609 --- /dev/null +++ b/resources/views/components/ui/row.blade.php @@ -0,0 +1,27 @@ +@props([ + 'label' => null, + 'hint' => null, + 'for' => null, +]) + +{{-- One row of a settings panel: what it is on the left, the control on the + right. + + The two-column shape is what uses the width. A single column of full-width + inputs on a 1240px shell leaves two thirds of the line empty and makes a + four-field form look like a questionnaire; a label column of 280px with the + control beside it reads as a settings page and fits the screen it is on. + + Stacks below `sm`, because on a telephone the label belongs above its + field. --}} +
merge(['class' => 'grid gap-x-6 gap-y-2 px-6 py-4 sm:grid-cols-[minmax(0,280px)_minmax(0,1fr)] sm:items-start']) }}> + @if ($label) +
+ + @if ($hint) +

{{ $hint }}

+ @endif +
+ @endif +
{{ $slot }}
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2663fd1..2298e20 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,17 +1,26 @@ {{-- The customer's own settings. - It was one column 768 pixels wide inside a 1240-pixel shell — a third of the - window empty beside it — holding everything a customer might ever change: - the company address, the password, two-factor, the signed-in devices, the - branding, the contract and the button that closes the account. Two thousand - pixels of scroll, and the way to a particular thing was knowing how far down - it lived. + ## What was wrong, three times over - Four tabs, by what somebody came to do. Inside a tab the short cards sit side - by side instead of each taking a full row to hold three lines. The choice is - in the query string (see the #[Url] attribute on the component), so a reload, - a bookmark and the back button all land where they were. + First it was one column 768px wide in a 1240px shell, holding everything a + customer might ever change, two thousand pixels deep. Then it became four + tabs — better — but the cards inside them were still boxes of different + heights in a grid, which on the contract tab produced a short one, a wide one + and a short one again: a staircase with no rhythm and half the width empty + beside it. + + ## What it is now + + Panels and rows (x-ui.panel, x-ui.row). A group is a card with a header that + names it; a setting is a ROW inside that card — what it is on the left, the + control on the right, dividers between. That is the shape every settings page + worth copying uses, and it is the shape that uses the width: a label column + of 280px with the control beside it fills the line, where a stack of + full-width inputs leaves two thirds of it empty. + + Below `sm` the rows stack, because on a telephone a label belongs above its + field. --}}
@@ -24,7 +33,8 @@ {{-- The same tab bar the console uses, so one vocabulary holds on both sides - of the login. --}} + of the login. The choice lives in the query string — see the #[Url] + attribute on the component. --}}
@foreach ($tabs as $name) + @endif + @error('logo')

{{ $message }}

@enderror +
+
+ -
- -
- @if ($logo) - - @elseif ($logoUrl) - - @else - {{ __('settings.brand_default') }} + + @if ($branding['is_default'] ?? false) +

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

@endif -
- -

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

- @if ($logoUrl) - - @endif - @error('logo')

{{ $message }}

@enderror - @if ($branding['is_default'] ?? false) -

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

- @endif -
+ + {{ __('settings.save') }} + + + @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. --}} + {{-- ══ Vertrag ══════════════════════════════════════════════════════════ --}} @if ($tab === 'contract')
- {{-- Package, cancellation and — while it is open — the withdrawal. --}} -
-
-
-

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

-

+ + + {{-- The package, and the way out of it. --}} + +

+

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

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

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

+ @if ($hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.cancel_cta') }} + + @elseif (! $hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.to_packages') }} + + @endif
- @endif + - {{-- The fourteen-day right of withdrawal. Shown only where it - exists: a business customer has none and never sees this - block — and the server refuses them again in - Settings::withdraw(), because a card that is not rendered has + {{-- ── Das Widerrufsrecht ──────────────────────────────────── + Shown to every CONSUMER with a contract, open or not — not + only while it is open. It vanished entirely once the + fourteen days ran out, which is indistinguishable from a + feature that was never built: the owner went looking for it + and reported it missing from an account that has no contract + at all, so there was nothing for it to be about. + + A business never sees it, and the server refuses them again + in Settings::withdraw() — a card that is not rendered has never stopped anybody who can post to /livewire/update. - Separate from the cancellation above it, and not a variant of - it. A cancellation ends a contract that was validly concluded - and keeps the term the customer paid for; a withdrawal - 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(), - ]) }} + Not a variant of the cancellation above: that one ends a + contract validly concluded and keeps the term already paid + for; this one unwinds the contract, ends the service the same + day and sends the whole amount back. --}} + @if ($withdrawal->applies) + +

+

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

-
- - {{ __('withdrawal.cta') }} - -
- @endif -
- - {{-- ── 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. --}} - @if ($dpa !== null) -
-
-
-

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

-

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

-
- - {{ __('dpa.version', ['version' => $dpa->version]) }} - -
- -
- - {{ __('dpa.read_agreement') }} - - @if ($dpa->measures_path) - - {{ __('dpa.read_measures') }} - - @endif - {{-- Keep, not just read: the file carries the version in - its name, so which fassung applied is answerable from - a downloads folder months later. --}} - - {{ __('dpa.download_agreement') }} - - @if ($dpa->measures_path) - - {{ __('dpa.download_measures') }} - - @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') }}

+ @if ($withdrawal->open) - {{ __('dpa.accept_cta') }} + x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })"> + {{ __('withdrawal.cta') }} + @endif +
+ + @endif + + {{-- ── 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. The extra confirmation is offered because a + practice or a firm that gets audited often wants one on file. + + Nothing renders until an operator has published a version. --}} + @if ($dpa !== null) + +
+
+ + {{ __('dpa.read_agreement') }} + + + {{ __('dpa.download') }} + + @if ($dpa->measures_path) + + + {{ __('dpa.read_measures') }} + + + {{ __('dpa.download') }} + + @endif + {{ __('dpa.version', ['version' => $dpa->version]) }}
- @endif -
-
- @endif - {{-- Closing the account, and the deadlines that close one by itself. --}} -
-
-
-

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

-

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

-
- - {{ __('settings.close_cta') }} - -
+ @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 - {{-- The deadlines, said where somebody asks the question. - "Nach fünf Tagen gelöscht" on the verification page reads as - if it applied to every account; it applies to a registration - 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. --}} + {{-- Closing the account. Last row of the panel, where the + irreversible things belong. --}} + +
+ + {{ __('settings.close_cta') }} + +
+
+ + + {{-- The deadlines, said where somebody asks the question. "Nach fünf + Tagen gelöscht" on the verification page reads as if it applied + to every account; it applies to a registration 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]) }}
    • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
    • {{ __('settings.lifecycle_kept') }}
    • @@ -451,7 +450,7 @@ {{ __('settings.lifecycle_terms') }}
- +
@endif
diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index e8971bb..5d9a557 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -377,3 +377,71 @@ it('never offers a business the withdrawal it does not have', function () { expect(Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html()) ->not->toContain(__('withdrawal.instead_title')); }); + +it('shows a consumer their withdrawal right, expired as well as open', function () { + // It used to render only while the window was open, so once the fourteen + // days ran out it vanished — indistinguishable from a feature that was never + // built. Which is exactly how it was reported missing. + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_CONSUMER]); + $instance = $customer->instances()->where('status', 'active')->first(); + + $contract = App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + 'started_at' => now()->subDay(), + 'withdrawal_ends_at' => now()->addDays(13), + ]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('withdrawal.card_title')) + ->assertSee(__('withdrawal.cta')); + + // Expired: still stated, with the reason, and no button. + $contract->update(['withdrawal_ends_at' => now()->subDay()]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('withdrawal.card_title')) + ->assertDontSee(__('withdrawal.cta')); +}); + +it('never shows a business a right it does not have', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); + $instance = $customer->instances()->where('status', 'active')->first(); + App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + 'started_at' => now()->subDay(), + 'withdrawal_ends_at' => now()->addDays(13), + ]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertDontSee(__('withdrawal.card_title')); +}); + +it('offers the packages to somebody who has none, instead of a dead sentence', function () { + $user = App\Models\User::factory()->create(['email_verified_at' => now()]); + App\Models\Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('settings.no_package')) + ->assertSee(__('settings.to_packages')) + // And nothing about withdrawing, because there is nothing to withdraw + // from — which is what the owner ran into looking for the button. + ->assertDontSee(__('withdrawal.card_title')); +}); + +it('builds every tab out of the same two pieces', function () { + // Panels and rows, not a heap of cards of different heights: the group is + // the card, the setting is a row inside it, and the label column is what + // uses the width. + $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php')); + + expect(substr_count($page, 'toBeGreaterThanOrEqual(5) + ->and(substr_count($page, 'toBeGreaterThanOrEqual(12) + // No hand-rolled card markup left behind. + ->and($page)->not->toContain('rounded-lg border border-line bg-surface p-6 shadow-xs'); +}); From 4b0eebeccc66ad0709855d456159668bab0cbef7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:38:57 +0200 Subject: [PATCH 10/12] Release v1.3.62 Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 160c739..02c1916 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.61 +1.3.62 From 5883989552e6ef2db6c1503069a909ee8ca05ab4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:59:16 +0200 Subject: [PATCH 11/12] Give settings the shape a settings page actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four attempts, and this is what was wrong with the last one: full-width panels stacked down the page, each carrying its own title INSIDE it. Every group had two frames — the title bar and the border — so the page read as a column of long boxes with nothing saying where one topic ended and the next began, and a phone number got a field a thousand pixels wide. The shape now: - **A section list on the left**, sticky, so the sections stay reachable however far a form runs. Below `lg` it becomes a scrolling strip of chips, because a vertical list on a telephone is four rows of nothing. Under it, who is signed in — on a shared office machine that is a real question, and a settings page is where it gets asked. - **A content column of 820px**, not the whole 1240 shell. - **The heading OUTSIDE the card it belongs to.** A section is a heading, a sentence, and then a card of rows. That single move is what stops them reading as boxes: the border now frames the fields, not the topic. - **What cannot be undone is last, in a card whose border says so** — the deletion deadlines and the close button together, at the foot of the contract section, in danger colours. The rows are unchanged (label left, control right) and so is every binding; what changed is the frame around them. Co-Authored-By: Claude Opus 5 --- lang/de/dpa.php | 2 + lang/de/settings.php | 1 + lang/en/dpa.php | 2 + lang/en/settings.php | 1 + resources/views/livewire/settings.blade.php | 913 +++++++++++--------- 5 files changed, 507 insertions(+), 412 deletions(-) diff --git a/lang/de/dpa.php b/lang/de/dpa.php index 6f79b85..6d69a3d 100644 --- a/lang/de/dpa.php +++ b/lang/de/dpa.php @@ -15,4 +15,6 @@ return [ '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', + 'open' => 'Öffnen', + 'state_label' => 'Status', ]; diff --git a/lang/de/settings.php b/lang/de/settings.php index 098d932..c1136a4 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -11,6 +11,7 @@ return [ 'branding' => 'Erscheinungsbild', 'contract' => 'Vertrag', ], + 'signed_in_as' => 'Angemeldet als', 'title' => 'Einstellungen', 'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.', 'save' => 'Speichern', diff --git a/lang/en/dpa.php b/lang/en/dpa.php index 4c0b3a5..1a95a8f 100644 --- a/lang/en/dpa.php +++ b/lang/en/dpa.php @@ -15,4 +15,6 @@ return [ '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', + 'open' => 'Open', + 'state_label' => 'State', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index e72e94f..25f2292 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -11,6 +11,7 @@ return [ 'branding' => 'Appearance', 'contract' => 'Contract', ], + 'signed_in_as' => 'Signed in as', 'title' => 'Settings', 'subtitle' => 'Everything that belongs to your account, grouped by subject.', 'save' => 'Save', diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2298e20..32fcf95 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,27 +1,46 @@ {{-- The customer's own settings. - ## What was wrong, three times over + ## Four attempts, and what was wrong with each - First it was one column 768px wide in a 1240px shell, holding everything a - customer might ever change, two thousand pixels deep. Then it became four - tabs — better — but the cards inside them were still boxes of different - heights in a grid, which on the contract tab produced a short one, a wide one - and a short one again: a staircase with no rhythm and half the width empty - beside it. + 1. One column 768px wide in a 1240px shell, everything in it, two thousand + pixels deep. + 2. Four tabs — better — but cards of different heights in a grid, which on + the contract tab read as a staircase. + 3. Panels and rows: right idea, wrong proportions. Full-width slabs stacked + down the page, each with its title INSIDE it, so the page was a column of + long boxes and nothing said where one topic ended. ## What it is now - Panels and rows (x-ui.panel, x-ui.row). A group is a card with a header that - names it; a setting is a ROW inside that card — what it is on the left, the - control on the right, dividers between. That is the shape every settings page - worth copying uses, and it is the shape that uses the width: a label column - of 280px with the control beside it fills the line, where a stack of - full-width inputs leaves two thirds of it empty. + The shape serious settings pages actually use: a section list on the left, + a measured content column on the right, and the heading OUTSIDE the card it + belongs to. - Below `sm` the rows stack, because on a telephone a label belongs above its - field. + - The nav is the structure. It is sticky, so the sections stay reachable + however far down a form goes, and it says which one you are in without + making you read a row of tabs. + - 820px is the content column. Nobody types a phone number into a field + 1100px wide; a settings page that lets them looks like a spreadsheet. + - A section is a heading, a sentence, and then a card of rows. Putting the + heading inside the card gave every group two frames — the title bar and + the border — and that is what made them read as boxes rather than as + topics. + - What cannot be undone lives at the end, in a card that says so with its + border. + + Below `lg` the nav becomes a scrolling strip of chips above the content, and + the rows stack, because on a telephone a label belongs above its field. --}} +@php + $sections = [ + 'profile' => 'users', + 'security' => 'shield-check', + 'branding' => 'pen', + 'contract' => 'receipt', + ]; +@endphp +
@@ -32,425 +51,495 @@

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

- {{-- The same tab bar the console uses, so one vocabulary holds on both sides - of the login. The choice lives in the query string — see the #[Url] - attribute on the component. --}} -
- @foreach ($tabs as $name) - - @endforeach -
+
- {{-- ══ Ihre Daten ═══════════════════════════════════════════════════════ --}} - @if ($tab === 'profile') -
- - - - - - - - - - - - - + {{-- ── The section list ──────────────────────────────────────────── + Sticky on a wide screen: a long form must not scroll its own + navigation away. A strip of chips below `lg`, because a vertical + list on a telephone is four rows of nothing. --}} + - {{-- A FACT for a record that has one. It decides the fourteen-day - right of withdrawal: a business able to set itself to - "Privatperson" here is a business able to withdraw from a - contract it may not withdraw from. Registration asks; this - page reports. The lock is in Settings::saveProfile, not here - — a form that only hides a control has never stopped anybody - who can post to /livewire/update. --}} - - @if ($customer?->customer_type) -

- - {{ __('settings.customer_type_'.$customer->customer_type) }} + {{-- ── The content column ─────────────────────────────────────────── + 820px, not the whole shell. Nobody types a postcode into a field a + thousand pixels wide. --}} +

+ + {{-- ══ Ihre Daten ═══════════════════════════════════════════════ --}} + @if ($tab === 'profile') + +
+

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

+

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

+ + + + + + + + + + + + + + + + {{-- Field by field, not a paragraph: what landed in + the textarea was whatever somebody typed — a + postcode on the street line, a town with no + postcode, no country at all — and an invoice has + to name its recipient precisely enough to be a + document. --}} + +
+ +
+ + +
+ +
+
+ + + + {{ __('settings.save') }} + + +
+
+ + {{-- A FACT for a record that has one. It decides the + fourteen-day right of withdrawal: a business able to set + itself to "Privatperson" here is a business able to + withdraw from a contract it may not withdraw from. + Registration asks; this page reports. The lock is in + Settings::saveProfile, not here — a form that only hides + a control has never stopped anybody who can post to + /livewire/update. --}} +
+

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

+

+ {{ $customer?->customer_type ? __('settings.customer_type_locked') : __('settings.customer_type_once') }}

- @else -
- @foreach ($customerTypes as $type) - - @endforeach -
- @error('customerType')

{{ $message }}

@enderror - @endif - - - - {{ __('settings.save') }} - - - - - @endif + +
+ @if ($customer?->customer_type) +

+ + {{ __('settings.customer_type_'.$customer->customer_type) }} +

+ @else +
+ @foreach ($customerTypes as $type) + + @endforeach +
+ @error('customerType')

{{ $message }}

@enderror + @endif +
+
+
+ + @endif - {{-- ══ Sicherheit ═══════════════════════════════════════════════════════ --}} - @if ($tab === 'security') -
+ {{-- ══ Sicherheit ═══════════════════════════════════════════════ --}} + @if ($tab === 'security') +
- {{-- Own password. There was no way to change one at all — Fortify's - updatePasswords feature was switched off. --}} -
- - - - - -
- - -
-
+ {{-- Own password. There was no way to change one at all — + Fortify's updatePasswords feature was switched off. --}} +
+

{{ __('admin_settings.password_title') }}

+

{{ __('admin_settings.password_sub') }}

- - - {{ __('admin_settings.password_save') }} - - - - +
+ + + + + +
+ + +
+
- {{-- Two-factor. Every action is re-checked server-side against a - recent password confirmation — hiding the buttons stops nobody - who can post to /livewire/update. --}} - - -
+ + + {{ __('admin_settings.password_save') }} + + + + +
+ + {{-- Two-factor. Every action is re-checked server-side + against a recent password confirmation — hiding the + buttons stops nobody who can post to /livewire/update. --}} +
-

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

+

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

{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
-

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

-
- +

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

- @if (! $passwordConfirmed) - {{-- The gate. A signed-in session is not enough to change how - the account is protected — the risk is an unlocked - machine. --}} -
- -
-
- -
- - {{ __('settings.twofa_confirm_button') }} - -
-
-
- @elseif ($twoFactorOn) - -
- - {{ __('settings.twofa_new_codes') }} - - - {{ __('settings.twofa_disable') }} - -
-
- @elseif ($twoFactorPending) - -
-
{!! $twoFactorQr !!}
-
-
- -
- - {{ __('settings.twofa_activate') }} - -
-
-
- @else - - - {{ __('settings.twofa_enable') }} - - - @endif - - @if ($recoveryCodes) - {{-- Shown once, on purpose: nobody writes down what they were - never shown, and these are the way back in when the phone - is gone. --}} - -
    - @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach -
-
- @endif - - - {{-- The devices. It answers the same question two-factor does — who - can get into this account — and it is a table of its own. --}} - @livewire('sessions') -
- @endif - - {{-- ══ Erscheinungsbild ═════════════════════════════════════════════════ --}} - @if ($tab === 'branding') -
- - - - - - -
- - -
- @error('brandPrimary')

{{ $message }}

@enderror -
- - -
- - -
- @error('brandAccent')

{{ $message }}

@enderror -
- - -
-
- @if ($logo) - - @elseif ($logoUrl) - + + @if (! $passwordConfirmed) + {{-- The gate. A signed-in session is not enough + to change how the account is protected — the + risk is an unlocked machine. --}} + + +
+
+ +
+ + {{ __('settings.twofa_confirm_button') }} + +
+
+ + @elseif ($twoFactorOn) + +
+ + {{ __('settings.twofa_new_codes') }} + + + {{ __('settings.twofa_disable') }} + +
+
+ @elseif ($twoFactorPending) + +
+
{!! $twoFactorQr !!}
+
+
+ +
+ + {{ __('settings.twofa_activate') }} + +
+
+
@else - {{ __('settings.brand_default') }} - @endif -
-
- - @if ($logoUrl) - - @endif - @error('logo')

{{ $message }}

@enderror -
-
-
- - - @if ($branding['is_default'] ?? false) -

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

- @endif - - {{ __('settings.save') }} - -
-
- - @endif - - {{-- ══ Vertrag ══════════════════════════════════════════════════════════ --}} - @if ($tab === 'contract') -
- - - - {{-- The package, and the way out of it. --}} - -
-

- @if ($cancellationScheduled) - {{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }} - @elseif ($hasActivePackage) - {{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }} - @else - {{ __('settings.no_package') }} - @endif -

- - @if ($hasActivePackage && ! $cancellationScheduled) - - {{ __('settings.cancel_cta') }} - - @elseif (! $hasActivePackage && ! $cancellationScheduled) - - {{ __('settings.to_packages') }} - - @endif -
-
- - {{-- ── Das Widerrufsrecht ──────────────────────────────────── - Shown to every CONSUMER with a contract, open or not — not - only while it is open. It vanished entirely once the - fourteen days ran out, which is indistinguishable from a - feature that was never built: the owner went looking for it - and reported it missing from an account that has no contract - at all, so there was nothing for it to be about. - - A business never sees it, and the server refuses them again - in Settings::withdraw() — a card that is not rendered has - never stopped anybody who can post to /livewire/update. - - Not a variant of the cancellation above: that one ends a - contract validly concluded and keeps the term already paid - for; this one unwinds the contract, ends the service the same - day and sends the whole amount back. --}} - @if ($withdrawal->applies) - -
-

- @if ($withdrawal->open) - {{ __('withdrawal.card_sub', [ - 'date' => $withdrawal->endsAt->local()->isoFormat('LL'), - 'days' => $withdrawal->daysLeft(), - ]) }} - @else - {{ $withdrawal->refusal }} - @endif -

- @if ($withdrawal->open) - - {{ __('withdrawal.cta') }} - - @endif -
-
- @endif - - {{-- ── 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. The extra confirmation is offered because a - practice or a firm that gets audited often wants one on file. - - Nothing renders until an operator has published a version. --}} - @if ($dpa !== null) - -
-
- - {{ __('dpa.read_agreement') }} - - - {{ __('dpa.download') }} - - @if ($dpa->measures_path) - - - {{ __('dpa.read_measures') }} + + + {{ __('settings.twofa_enable') }} - - {{ __('dpa.download') }} - - @endif - {{ __('dpa.version', ['version' => $dpa->version]) }} -
- - @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. Last row of the panel, where the - irreversible things belong. --}} - -
- - {{ __('settings.close_cta') }} - -
-
-
+ @if ($recoveryCodes) + {{-- Shown once, on purpose: nobody writes down + what they were never shown, and these are the + way back in when the phone is gone. --}} + +
    + @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach +
+
+ @endif + + - {{-- The deadlines, said where somebody asks the question. "Nach fünf - Tagen gelöscht" on the verification page reads as if it applied - to every account; it applies to a registration 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_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • -
  • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
  • -
  • {{ __('settings.lifecycle_kept') }}
  • -
- - {{ __('settings.lifecycle_terms') }} - + {{-- The devices. It answers the same question two-factor does + — who can get into this account. --}} +
+ @livewire('sessions') +
-
+ @endif + + {{-- ══ Erscheinungsbild ═════════════════════════════════════════ --}} + @if ($tab === 'branding') +
+
+

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

+

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

+ + + + + + + +
+ + +
+ @error('brandPrimary')

{{ $message }}

@enderror +
+ + +
+ + +
+ @error('brandAccent')

{{ $message }}

@enderror +
+ + +
+
+ @if ($logo) + + @elseif ($logoUrl) + + @else + {{ __('settings.brand_default') }} + @endif +
+
+ + @if ($logoUrl) + + @endif + @error('logo')

{{ $message }}

@enderror +
+
+
+ + + @if ($branding['is_default'] ?? false) +

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

+ @endif + + {{ __('settings.save') }} + +
+
+
+
+ @endif + + {{-- ══ Vertrag ══════════════════════════════════════════════════ --}} + @if ($tab === 'contract') +
+ +
+

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

+

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

+ + + +
+

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

+ + @if ($hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.cancel_cta') }} + + @elseif (! $hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.to_packages') }} + + @endif +
+
+ + {{-- ── Das Widerrufsrecht ──────────────────────── + Shown to every CONSUMER with a contract, open or + not. It used to render only while the window was + open, so once the fourteen days expired the block + vanished — indistinguishable from a feature + nobody built. + + A business never sees it, and Settings::withdraw() + refuses them again on the server: a card that is + not rendered has never stopped anybody who can + post to /livewire/update. + + Not a variant of the cancellation above it: that + one ends a contract validly concluded and keeps + the term already paid for; this one unwinds the + contract, ends the service the same day and sends + the whole amount back. --}} + @if ($withdrawal->applies) + +
+

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

+ @if ($withdrawal->open) + + {{ __('withdrawal.cta') }} + + @endif +
+
+ @endif +
+
+ + {{-- ── 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. The extra + confirmation is offered because a practice or a firm that + gets audited often wants one on file. + + Nothing renders until an operator has published a + version. --}} + @if ($dpa !== null) +
+
+

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

+ {{ __('dpa.version', ['version' => $dpa->version]) }} +
+

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

+ + + +
+ + {{ __('dpa.open') }} + + + {{ __('dpa.download') }} + +
+
+ + @if ($dpa->measures_path) + +
+ + {{ __('dpa.open') }} + + + {{ __('dpa.download') }} + +
+
+ @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 + + {{-- ── What ends an account ────────────────────────────── + The deadlines and the button that closes it, together and + last, in a card whose border says what kind of thing this + is. Everything irreversible on this page lives here. + + The deadlines come from the commands that enforce them, + so the page cannot drift from what actually happens. --}} +
+

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

+

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

+ + + +
    +
  • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • +
  • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
  • +
  • {{ __('settings.lifecycle_kept') }}
  • +
+ + {{ __('settings.lifecycle_terms') }} + +
+ + +
+ + {{ __('settings.close_cta') }} + +
+
+
+
+
+ @endif
- @endif +
From 69a9322a4efb1af9d10026363c0b20db55403ad2 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:59:16 +0200 Subject: [PATCH 12/12] Release v1.3.63 Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 02c1916..fe0c4db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.62 +1.3.63