diff --git a/VERSION b/VERSION index 936003c..fe0c4db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.57 +1.3.63 diff --git a/app/Console/Commands/PublishProcessingAgreement.php b/app/Console/Commands/PublishProcessingAgreement.php new file mode 100644 index 0000000..4a97309 --- /dev/null +++ b/app/Console/Commands/PublishProcessingAgreement.php @@ -0,0 +1,85 @@ +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'; + + // 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, + 'agreement_path' => $agreementPath, + 'measures_path' => $measuresPath, + // 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(), + ]); + + 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)'); + + return self::SUCCESS; + } + + private function slug(string $version): string + { + return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version'; + } +} 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..6a35497 --- /dev/null +++ b/app/Livewire/Admin/ProcessingAgreements.php @@ -0,0 +1,137 @@ +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 — 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', 'public'), + 'measures_path' => $this->measures?->store('dpa', 'local', 'public'), + '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/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/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..6d69a3d --- /dev/null +++ b/lang/de/dpa.php @@ -0,0 +1,20 @@ + '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.', + 'read_agreement' => 'AV-Vertrag ansehen', + 'read_measures' => 'TOM ansehen', + 'version' => 'Fassung :version', + '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', + 'open' => 'Öffnen', + 'state_label' => 'Status', +]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php new file mode 100644 index 0000000..20b1b2d --- /dev/null +++ b/lang/de/dpa_admin.php @@ -0,0 +1,36 @@ + '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 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', + '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.', + 'download' => 'Herunterladen', +]; diff --git a/lang/de/settings.php b/lang/de/settings.php index fc58f40..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', @@ -49,6 +50,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/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..1a95a8f --- /dev/null +++ b/lang/en/dpa.php @@ -0,0 +1,20 @@ + '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.', + 'read_agreement' => 'Read the agreement', + 'read_measures' => 'Read the measures', + 'version' => 'Version :version', + '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', + 'open' => 'Open', + 'state_label' => 'State', +]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php new file mode 100644 index 0000000..e1ee1bb --- /dev/null +++ b/lang/en/dpa_admin.php @@ -0,0 +1,36 @@ + '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 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', + '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.', + 'download' => 'Download', +]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 158611a..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', @@ -49,6 +50,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/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)

+ + + +

3. Zugriffskontrolle (Berechtigungen)

+ + + +

4. Trennungskontrolle (Mandantentrennung)

+ + + +

5. Weitergabekontrolle (Transport und Ablage)

+ + + +

6. Eingabekontrolle (Nachvollziehbarkeit)

+ + + +

7. Verfügbarkeit und Belastbarkeit

+ + + +

8. Löschung und Aufbewahrung

+ + + +

9. Auftragskontrolle und Überprüfung

+ + 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/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..ce81e8a --- /dev/null +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -0,0 +1,159 @@ +{{-- + 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 }} +
+ + {{ __('dpa_admin.file_agreement') }} + + @if ($v->measures_path) + + {{ __('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') }} + +
+
+ @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..32fcf95 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,18 +1,46 @@ {{-- 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. + ## Four attempts, and what was wrong with each - 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. + 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 + + 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. + + - 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 +
@@ -23,352 +51,495 @@

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

- {{-- The same tab bar the console uses, so one vocabulary holds on both sides - of the login. --}} -
- @foreach ($tabs as $name) - - @endforeach -
+
- {{-- ── 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 city with no postcode, no country at all — - and an invoice has to name its recipient precisely enough to - be a document. Postcode and town share a row because that is - how they are read and written. --}} -
-

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

- -
- - -
- -
- -
- {{ __('settings.save') }} -
+ {{-- ── 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. --}} + - {{-- ── Sicherheit ───────────────────────────────────────────────────── --}} - @if ($tab === 'security') -
+ {{-- ── The content column ─────────────────────────────────────────── + 820px, not the whole shell. Nobody types a postcode into a field a + thousand pixels wide. --}} +
- {{-- 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') }}

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

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

+

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

- -
- - -
+ + + + + + + + + + + + + -
- {{ __('admin_settings.password_save') }} -
- + {{-- 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. --}} + +
+ +
+ + +
+ +
+
- {{-- 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') }}

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

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

-
+ + + {{ __('settings.save') }} + + + +
- @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_first') }}

-
-
- -
- - {{ __('settings.twofa_confirm_button') }} - -
-
- @elseif ($twoFactorOn) -
- - {{ __('settings.twofa_new_codes') }} - - - {{ __('settings.twofa_disable') }} - -
- @elseif ($twoFactorPending) -
-
{!! $twoFactorQr !!}
-
-

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

-
- -
- - {{ __('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. --}} -
-

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

-

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

-
    - @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach -
-
- @endif -
- - {{-- Under both, across the width: it answers the same question they - do — who can get into this account — and it is a table. --}} -
- @livewire('sessions') -
-
- @endif - - {{-- ── Erscheinungsbild ─────────────────────────────────────────────── --}} - @if ($tab === 'branding') -
-
-
-

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

-

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

-
- - - -
-
- -
- - -
- @error('brandPrimary')

{{ $message }}

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

{{ $message }}

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

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

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

{{ $message }}

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

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

- @endif -
-
- @endif - - {{-- ── Vertrag ──────────────────────────────────────────────────────── --}} - @if ($tab === 'contract') -
- -
-

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

- - @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) : '—']) }}

- - {{ __('settings.cancel_cta') }} - -
- @else -

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

- @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 - 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(), - ]) }} + {{-- 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') }}

- - {{ __('withdrawal.cta') }} - -
- @endif -
-
-
-
-

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

-

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

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

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

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

{{ $message }}

@enderror + @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. --}} -
-

{{ __('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') }}
  • -
- - {{ __('settings.lifecycle_terms') }} - + {{-- ══ Sicherheit ═══════════════════════════════════════════════ --}} + @if ($tab === 'security') +
+ + {{-- 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. --}} +
+
+

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

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

{{ __('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. --}} +
+ @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 +
diff --git a/routes/admin.php b/routes/admin.php index c236e5e..4d0049d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -59,6 +59,30 @@ 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. 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 // Admin\MailTemplates. Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates'); diff --git a/routes/web.php b/routes/web.php index e7e7cfe..006570e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -304,6 +304,32 @@ $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); + + // 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'); 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..f0ddc2e --- /dev/null +++ b/tests/Feature/ProcessingAgreementTest.php @@ -0,0 +1,345 @@ +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']); + + // 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.in_force_note')) + ->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 states the confirmation rather than asking again. + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee($acceptance->accepted_at->local()->isoFormat('LL, LT')) + ->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'); +}); + +// ---- 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'); +}); + +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'); +}); + +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'); +}); 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'); +}); 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()); } }