Merge branch 'main' into claude/cool-sammet-368fee
tests / pest (push) Failing after 9m11s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

feature/host-bootstrap
nexxo 2026-07-30 19:28:55 +02:00
commit ecfced9257
34 changed files with 2426 additions and 359 deletions

View File

@ -1 +1 @@
1.3.57
1.3.63

View File

@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Models\DpaVersion;
use App\Services\Legal\DpaRenderer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Render the processing agreement and its annex, store them, put them in force.
*
* The documents are generated from the repository (resources/views/legal/dpa),
* so a change to the wording is a change somebody can review in a diff and the
* company name, the sub-processors and the deletion deadlines come from the same
* places the rest of the application reads them, which is what stops the
* agreement drifting away from what the software does.
*
* The console's upload form still exists and is unchanged: a lawyer's revision
* arrives as a PDF and becomes a version like any other. This command is for the
* version this application maintains itself.
*/
class PublishProcessingAgreement extends Command
{
protected $signature = 'clupilot:publish-dpa
{version : What the document calls itself, e.g. 1.0}
{--draft : Store it without putting it in force}';
protected $description = 'Render the processing agreement and its annex, and put them in force';
public function handle(DpaRenderer $renderer): int
{
$version = (string) $this->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';
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Admin;
use App\Models\DpaVersion;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a processing agreement goes into force (R23).
*
* Publishing is not a save: acceptance is per version, so from this moment every
* customer who had accepted the previous one is outstanding again. That is the
* correct behaviour and an expensive one to trigger by a misplaced click.
*
* Mutates nothing itself it dispatches back to ProcessingAgreements, whose
* publish() keeps the permission check it already had.
*/
class ConfirmPublishDpa extends ModalComponent
{
public string $uuid;
public string $version = '';
public function mount(string $uuid): void
{
$this->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');
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\DpaVersion;
use App\Services\Legal\ProcessingAgreement;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* The data-processing agreement: upload a version, publish it, see who has it.
*
* The TEXT is not this application's and never will be. Art. 28(3) GDPR wants a
* contract between the controller (the customer) and the processor (us); what
* belongs in it is a lawyer's answer, and inventing one here would be worse than
* having none. What this page does is the part software can do properly: keep
* the document, say which version is in force, hand it to the customer, and hold
* the evidence that they accepted it.
*
* ## Publishing is what asks every customer again
*
* Acceptance is per VERSION. Publishing a new one therefore leaves every
* customer outstanding until they accept it, which is the correct behaviour and
* an expensive one to trigger by accident so a draft is uploaded first and
* published deliberately, in two steps.
*/
#[Layout('layouts.admin')]
class ProcessingAgreements extends Component
{
use WithFileUploads;
/** What the document itself calls this version — "1.0", "2026-08". */
public string $version = '';
public string $note = '';
/** The agreement, and the technical and organisational measures beside it. */
public $agreement = null;
public $measures = null;
public function mount(): void
{
$this->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);
}
}

View File

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

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* That a customer accepted a particular version, when, and from where.
*
* Evidence, so nothing here is ever updated: a new version means a new row. What
* makes it evidence rather than a flag is the three fields beside the link the
* moment, the address it came from, and the login that pressed the button.
*/
class DpaAcceptance extends Model
{
use HasFactory, HasUuid;
protected $fillable = ['customer_id', 'dpa_version_id', 'user_id', 'ip', 'accepted_at'];
protected function casts(): array
{
return ['accepted_at' => '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);
}
}

60
app/Models/DpaVersion.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* One published version of the data-processing agreement.
*
* The TEXT is not this application's. An operator uploads the document their
* lawyer wrote, names the version, and publishes it; everything here is about
* serving that file and knowing which one is in force. The technical and
* organisational measures ride along as a second file rather than as a second
* concept they are an annex to the agreement, and "which measures applied when
* this customer accepted" has to have one answer.
*/
class DpaVersion extends Model
{
use HasFactory, HasUuid;
protected $fillable = ['version', 'agreement_path', 'measures_path', 'note', 'published_at', 'created_by'];
protected function casts(): array
{
return ['published_at' => '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');
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Services\Legal;
use App\Console\Commands\PruneDormantAccounts;
use App\Console\Commands\PruneUnverifiedAccounts;
use App\Support\CompanyProfile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\View;
use TCPDF;
/**
* The processing agreement and its annex, as PDFs.
*
* TCPDF, because the invoices already render through it a second PDF engine
* for two documents a year would be a dependency nobody maintains. Its HTML
* support is not a browser's, so the two Blade views are deliberately plain:
* headings, paragraphs, lists, one table.
*
* ## The documents are generated, not uploaded
*
* They name the company from CompanyProfile and take their deadlines from the
* commands that enforce them, so the agreement cannot drift from the invoices or
* from what the software actually does. An operator can still upload a lawyer's
* revision as a new version the console does not care where a file came from.
*/
final class DpaRenderer
{
/**
* The sub-processors this installation actually talks to.
*
* Here rather than in the template because it is a fact about the system,
* not wording: hosting, payment and mail are the three places customer-
* related data leaves this application, and a list that quietly omits one is
* the single most common defect in a processing agreement.
*
* @return array<int, array<string, string>>
*/
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<string, mixed> $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');
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Services\Legal;
use App\Models\Customer;
use App\Models\DpaAcceptance;
use App\Models\DpaVersion;
use Illuminate\Support\Facades\Auth;
/**
* Which agreement is in force, and whether a customer has accepted it.
*
* One place, because the same questions are asked by the customer's own page, by
* the console's register and by anything that later chases the ones outstanding
* and three implementations of "has this customer accepted?" is three chances
* to answer differently a question that is evidence.
*
* ## Accepting an OLD version is not accepting
*
* A new version supersedes the last, so this asks about the version in force and
* nothing else. The old acceptance is kept it was true when it was made, and
* the history is the point but it stops satisfying the question.
*/
final class ProcessingAgreement
{
public function current(): ?DpaVersion
{
return DpaVersion::current();
}
/** The acceptance of the version in force, if there is one. */
public function acceptanceOf(?Customer $customer): ?DpaAcceptance
{
$current = $this->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(),
],
);
}
}

View File

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

View File

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The data-processing agreement, and the proof that a customer has it.
*
* Art. 28(3) GDPR requires one wherever personal data is processed on somebody
* else's behalf, which is the whole of what this product does. "In writing"
* there explicitly includes electronic form, so a document the customer can read
* and a recorded acceptance is enough a signature on paper is not required.
*
* Two tables, because they answer two different questions:
*
* - `dpa_versions` is the DOCUMENT. The operator uploads it (the text is a
* lawyer's, not this application's), gives it a version, and publishes. The
* technical and organisational measures are a second file rather than a
* second concept: they are an annex to the agreement and are versioned with
* it, so "which TOMs applied when they accepted" has one answer.
* - `dpa_acceptances` is the EVIDENCE: which customer accepted which version,
* when, from where, and which login did it. One row per acceptance and never
* an update a new version is a new row, and the history is the point.
*
* The file lives on the private disk. An agreement is not a public asset, and a
* guessable URL to it would be a list of who our customers are.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('dpa_versions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Publishing the processing agreement is a legal act, not an everyday one.
*
* Its own capability rather than settings.manage: whoever keeps the platform
* running does not thereby decide what every customer is asked to agree to.
*
* Guard `operator`, not `web`. RBAC moved to the operator guard on 2026-07-29
* (see that migration) every capability written under `web` after it lands in
* a guard nothing authenticates against, and the console answers 403 for a role
* that visibly has the permission. Which is exactly what happened here first.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

View File

@ -11,6 +11,7 @@ return [
],
'nav' => [
'dpa' => 'AV-Vertrag',
'mail_preview' => 'E-Mail-Vorschau',
'inbox' => 'Posteingang',
'mail_log' => 'Postausgang',

20
lang/de/dpa.php Normal file
View File

@ -0,0 +1,20 @@
<?php
// Der Auftragsverarbeitungsvertrag, wie ihn der Kunde sieht. Der TEXT des
// Vertrags liegt nicht hier — er wird in der Konsole hochgeladen.
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.',
'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',
];

36
lang/de/dpa_admin.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'eyebrow' => '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',
];

View File

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

View File

@ -11,6 +11,7 @@ return [
],
'nav' => [
'dpa' => 'Processing agreement',
'mail_preview' => 'Mail preview',
'inbox' => 'Inbox',
'mail_log' => 'Mail sent',

20
lang/en/dpa.php Normal file
View File

@ -0,0 +1,20 @@
<?php
// The processing agreement as the customer sees it. The TEXT of the agreement
// is not here — it is uploaded in the console.
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.',
'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',
];

36
lang/en/dpa_admin.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'eyebrow' => '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',
];

View File

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

View File

@ -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. --}}
<section {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface shadow-xs']) }}>
@if ($title || isset($header))
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-line px-6 py-4">
@isset($header)
{{ $header }}
@else
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ $title }}</h2>
@if ($subtitle)
<p class="mt-0.5 max-w-[76ch] text-sm leading-relaxed text-muted">{{ $subtitle }}</p>
@endif
</div>
@isset($action)
<div class="shrink-0">{{ $action }}</div>
@endisset
@endisset
</div>
@endif
<div class="divide-y divide-line">{{ $slot }}</div>
@isset($footer)
<div class="flex flex-wrap items-center justify-end gap-3 border-t border-line bg-surface-2 px-6 py-4">
{{ $footer }}
</div>
@endisset
</section>

View File

@ -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. --}}
<div {{ $attributes->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)
<div class="min-w-0">
<label @if ($for) for="{{ $for }}" @endif class="text-sm font-medium text-ink">{{ $label }}</label>
@if ($hint)
<p class="mt-0.5 text-xs leading-relaxed text-muted">{{ $hint }}</p>
@endif
</div>
@endif
<div class="min-w-0">{{ $slot }}</div>
</div>

View File

@ -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
<h1>Vertrag über die Auftragsverarbeitung</h1>
<p><i>gemäß Art. 28 der Verordnung (EU) 2016/679 (DSGVO)</i></p>
<p><b>Fassung {{ $version }}</b> · Stand {{ $issuedOn }}</p>
<h2>Die Vertragsparteien</h2>
<p><b>Auftragsverarbeiter</b> (im Folgenden „CluPilot"):<br />
{{ $c['name'] ?? 'CluPilot Cloud e.U.' }}<br />
{{ $c['address'] ?? '' }}<br />
{{ $seat }}, {{ $c['country'] ?? 'Österreich' }}<br />
@if ($real($c['register_number'] ?? null)){{ $c['register_number'] }}, {{ $c['register_court'] ?? '' }}<br />@endif
@if ($real($c['vat_id'] ?? null)){{ $c['vat_id'] }}<br />@endif
{{ $c['email'] ?? '' }}</p>
<p><b>Verantwortlicher</b> (im Folgenden „Kunde"):<br />
die im Kundenkonto hinterlegten Stammdaten. Der Kunde hält diese Angaben aktuell; sie
sind Bestandteil dieses Vertrags.</p>
<p>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.</p>
<h2>1. Gegenstand und Dauer</h2>
<p>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.</p>
<p>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.</p>
<h2>2. Art, Zweck und Umfang der Verarbeitung</h2>
<p>2.1 <b>Zweck:</b> 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).</p>
<p>2.2 <b>Art der Verarbeitung:</b> Speichern, Aufbewahren, Sichern, Übermitteln im
Rahmen des Dienstes, Löschen. Eine inhaltliche Auswertung der Kundendaten findet nicht
statt.</p>
<p>2.3 <b>Kategorien betroffener Personen:</b> 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.</p>
<p>2.4 <b>Kategorien personenbezogener Daten:</b> 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.</p>
<h2>3. Weisungen</h2>
<p>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.</p>
<p>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).</p>
<p>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.</p>
<h2>4. Vertraulichkeit</h2>
<p>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.</p>
<h2>5. Technische und organisatorische Maßnahmen</h2>
<p>5.1 CluPilot trifft die Maßnahmen nach Art. 32 DSGVO. Sie sind in der <b>Anlage 1
(TOM)</b> beschrieben, die Bestandteil dieses Vertrags ist.</p>
<p>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.</p>
<h2>6. Unterauftragsverarbeiter</h2>
<p>6.1 Der Kunde erteilt die allgemeine Genehmigung zur Beauftragung der nachstehenden
Unterauftragsverarbeiter (Art. 28 Abs. 4 DSGVO):</p>
<table border="1" cellpadding="4">
<tr>
<td width="30%"><b>Unternehmen</b></td>
<td width="30%"><b>Leistung</b></td>
<td width="40%"><b>Ort der Verarbeitung</b></td>
</tr>
@foreach ($subprocessors as $sub)
<tr>
<td>{{ $sub['name'] }}</td>
<td>{{ $sub['service'] }}</td>
<td>{{ $sub['location'] }}</td>
</tr>
@endforeach
</table>
<p>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.</p>
<p>6.3 CluPilot verpflichtet jeden Unterauftragsverarbeiter auf Pflichten, die diesem
Vertrag entsprechen, und haftet für dessen Verhalten wie für eigenes.</p>
<h2>7. Ort der Verarbeitung, Drittländer</h2>
<p>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.</p>
<h2>8. Unterstützung des Kunden</h2>
<p>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.</p>
<p>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.</p>
<p>8.3 <b>Verletzungen des Schutzes personenbezogener Daten</b> 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.</p>
<h2>9. Nachweise und Kontrollen</h2>
<p>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.</p>
<p>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.</p>
<h2>10. Löschung und Rückgabe</h2>
<p>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 <b>30 Tage</b> Zeit, sie abzuholen.</p>
<p>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.</p>
<p>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.</p>
<h2>11. Haftung</h2>
<p>Es gilt Art. 82 DSGVO. Im Übrigen richtet sich die Haftung nach dem Hauptvertrag.</p>
<h2>12. Schlussbestimmungen</h2>
<p>12.1 Änderungen bedürfen der Textform. Widerspricht dieser Vertrag dem Hauptvertrag in
Fragen des Datenschutzes, geht dieser Vertrag vor.</p>
<p>12.2 Ist eine Bestimmung unwirksam, bleibt der übrige Vertrag gültig.</p>
<p>12.3 Es gilt österreichisches Recht. Gerichtsstand ist, soweit gesetzlich zulässig,
{{ $c['city'] ?? 'Wien' }}.</p>
<p><b>Anlage 1:</b> Technische und organisatorische Maßnahmen (TOM), Fassung {{ $version }}.</p>

View File

@ -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
<h1>Anlage 1 Technische und organisatorische Maßnahmen</h1>
<p><i>gemäß Art. 32 DSGVO, Anlage zum Auftragsverarbeitungsvertrag</i></p>
<p><b>Fassung {{ $version }}</b> · Stand {{ $issuedOn }} · {{ $c['name'] ?? 'CluPilot Cloud e.U.' }}</p>
<p>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.</p>
<h2>1. Zutrittskontrolle (physisch)</h2>
<p>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.</p>
<h2>2. Zugangskontrolle (Systemzugang)</h2>
<ul>
<li>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.</li>
<li>Zwei-Faktor-Authentisierung (TOTP) steht sowohl Kundinnen und Kunden als auch
Betreibern zur Verfügung; für Betreiberkonten wird sie eingesetzt.</li>
<li>Sicherheitsrelevante Änderungen (Zwei-Faktor abschalten, Schlüssel speichern)
verlangen zusätzlich eine erneute Passwortbestätigung innerhalb der laufenden
Sitzung.</li>
<li>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.</li>
<li>Die Betreiberkonsole ist über eine eigene Adresse erreichbar und für das
öffentliche Internet gesperrt; der Zugang erfolgt über ein privates Netz
(WireGuard).</li>
<li>Der administrative Zugriff auf die Virtualisierungsplattform erfolgt über
API-Token je Host, nicht über gemeinsam genutzte Kennwörter.</li>
</ul>
<h2>3. Zugriffskontrolle (Berechtigungen)</h2>
<ul>
<li>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.</li>
<li>Betreiber- und Kundenkonten sind getrennte Identitäten in getrennten Tabellen
mit getrennten Anmeldewegen. Ein Kundenkonto kann keine Betreiberrechte
erhalten.</li>
<li>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.</li>
<li>Zugangsdaten und Schlüssel (Zahlungsdienst, DNS, Hosts, Mailkonten) werden
verschlüsselt gespeichert; der dafür verwendete Schlüssel ist vom
Anwendungsschlüssel getrennt.</li>
</ul>
<h2>4. Trennungskontrolle (Mandantentrennung)</h2>
<ul>
<li>Jede Kundin und jeder Kunde erhält eine <b>eigene virtuelle Maschine</b> mit
eigenem Betriebssystem, eigener Datenbank und eigenem Dateisystem. Es gibt
keine geteilte Nextcloud-Installation und keine gemeinsame Datenbank für
Kundeninhalte.</li>
<li>Jede Instanz hat eine eigene Adresse und ein eigenes Zertifikat.</li>
<li>Die Verwaltungsdaten von CluPilot (Verträge, Rechnungen, Betriebsdaten) sind
von den Kundeninhalten getrennt und liegen nicht in den Kundeninstanzen.</li>
</ul>
<h2>5. Weitergabekontrolle (Transport und Ablage)</h2>
<ul>
<li>Sämtlicher Verkehr zu Kundeninstanzen, Portal und Konsole läuft über HTTPS mit
Zertifikaten von Let's Encrypt; unverschlüsselte Verbindungen werden nicht
angeboten.</li>
<li>E-Mail wird über TLS an den Mailserver übergeben.</li>
<li>Administrativer Zugriff auf Hosts erfolgt über SSH beziehungsweise über die
API der Virtualisierungsplattform, jeweils verschlüsselt.</li>
<li>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.</li>
</ul>
<h2>6. Eingabekontrolle (Nachvollziehbarkeit)</h2>
<ul>
<li>Jede automatisierte Einrichtung, Änderung und Beendigung einer Instanz wird
Schritt für Schritt protokolliert und bleibt in der Konsole einsehbar.</li>
<li>Ausgehende E-Mails an Kundinnen und Kunden werden mit Zeitpunkt, Empfänger und
Betreff in einem eigenen Register geführt.</li>
<li>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.</li>
<li>Anwendungs- und Systemprotokolle werden auf den Servern geführt.</li>
</ul>
<h2>7. Verfügbarkeit und Belastbarkeit</h2>
<ul>
<li><b>Sicherung:</b> Für jede Instanz wird bei der Einrichtung ein täglicher
Sicherungslauf um 02:00 Uhr auf der Virtualisierungsplattform eingerichtet
(Snapshot der gesamten Maschine).</li>
<li><b>Überwachung:</b> Erreichbarkeit und Zustand der Instanzen und Hosts werden
laufend überwacht; Störungen erzeugen Meldungen an den Betrieb.</li>
<li><b>Kapazität:</b> 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.</li>
<li><b>Aktualisierung:</b> Sicherheits- und Versionsaktualisierungen werden
eingespielt; geplante Wartungen werden angekündigt.</li>
</ul>
<h2>8. Löschung und Aufbewahrung</h2>
<ul>
<li>Nach Vertragsende werden die Daten zum Abruf bereitgestellt und nach Ablauf der
im Hauptvertrag genannten Frist samt Sicherungen gelöscht.</li>
<li>Registrierungen, deren E-Mail-Adresse nie bestätigt wurde, werden nach
{{ $unverifiedDays }} Tagen automatisch gelöscht.</li>
<li>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.</li>
<li>Rechnungen und Buchhaltungsunterlagen werden sieben Jahre aufbewahrt
(§ 132 BAO). Sie enthalten Vertrags-, nicht Inhaltsdaten.</li>
</ul>
<h2>9. Auftragskontrolle und Überprüfung</h2>
<ul>
<li>Unterauftragsverarbeiter werden vertraglich auf ein entsprechendes
Schutzniveau verpflichtet; die Liste ist Teil des Hauptvertrags.</li>
<li>Diese Maßnahmen werden bei jeder neuen Fassung des
Auftragsverarbeitungsvertrags überprüft, mindestens jedoch jährlich.</li>
<li>Ansprechstelle für Datenschutzfragen: {{ $c['email'] ?? '' }}</li>
</ul>

View File

@ -203,8 +203,12 @@
<p class="text-md leading-relaxed text-body">
Welche Daten CluPilot zu welchem Zweck verarbeitet, steht in der
<a href="{{ route('legal.datenschutz') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">Datenschutzerklärung</a>.
Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt dafür ein Vertrag über die
Auftragsverarbeitung nach Art. 28 DSGVO, den der Kunde anfordern kann.
Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt der Vertrag über die
Auftragsverarbeitung nach Art. 28 DSGVO in seiner jeweils geltenden Fassung. Er wird mit
diesen AGB Bestandteil des Vertrags; einer gesonderten Unterzeichnung bedarf es nicht.
Der Vertrag und die Dokumentation der technischen und organisatorischen Maßnahmen stehen
im Kundenbereich jederzeit zum Ansehen und Herunterladen bereit. Eine neue Fassung wird
dem Kunden mitgeteilt.
</p>
</section>

View File

@ -0,0 +1,15 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('dpa_admin.publish_title', ['version' => $version]) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('dpa_admin.publish_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">{{ __('dpa_admin.publish') }}</x-ui.button>
</div>
</div>

View File

@ -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.
--}}
<div class="space-y-6">
<header class="animate-rise">
<p class="lbl">{{ __('dpa_admin.eyebrow') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ __('dpa_admin.title') }}
</h1>
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('dpa_admin.subtitle') }}</p>
</header>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start">
{{-- ── Upload ─────────────────────────────────────────────────────── --}}
<form wire:submit="upload" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:40ms]">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('dpa_admin.upload_title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-muted">{{ __('dpa_admin.upload_sub') }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="version" wire:model="version" :label="__('dpa_admin.version')" :hint="__('dpa_admin.version_hint')" />
<x-ui.input name="note" wire:model="note" :label="__('dpa_admin.note')" :hint="__('dpa_admin.note_hint')" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body">{{ __('dpa_admin.file_agreement') }}</label>
<input type="file" wire:model="agreement" accept="application/pdf"
class="mt-1.5 w-full text-sm text-body file:mr-3 file:rounded file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
@error('agreement')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body">{{ __('dpa_admin.file_measures') }}</label>
<input type="file" wire:model="measures" accept="application/pdf"
class="mt-1.5 w-full text-sm text-body file:mr-3 file:rounded file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
@error('measures')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="upload,agreement,measures">
{{ __('dpa_admin.upload_cta') }}
</x-ui.button>
</div>
</form>
{{-- ── What is in force ───────────────────────────────────────────── --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:80ms]">
<h2 class="text-sm font-bold text-ink">{{ __('dpa_admin.current_title') }}</h2>
@if ($current === null)
<p class="text-sm leading-relaxed text-muted">{{ __('dpa_admin.none_yet') }}</p>
@else
<p class="font-mono text-lg font-semibold text-ink">{{ $current->version }}</p>
{{-- R19: stored in UTC, read on the wall clock. --}}
<p class="text-xs text-muted">{{ __('dpa_admin.in_force_since', ['when' => $current->published_at->local()->isoFormat('LL')]) }}</p>
<div class="border-t border-line pt-4">
<p class="text-sm text-body">
{{ __('dpa_admin.accepted_count', ['accepted' => $accepted, 'total' => $customers]) }}
</p>
@if ($outstanding > 0)
<p class="mt-1 text-sm font-semibold text-warning">{{ __('dpa_admin.outstanding', ['count' => $outstanding]) }}</p>
@endif
</div>
@endif
</div>
</div>
{{-- ── The versions ───────────────────────────────────────────────────── --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="border-b border-line px-6 py-4">
<h2 class="text-sm font-bold text-ink">{{ __('dpa_admin.versions_title') }}</h2>
</div>
@if ($versions->isEmpty())
<p class="px-6 py-10 text-center text-sm text-muted">{{ __('dpa_admin.no_versions') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line text-left">
<th class="px-6 py-3 font-medium text-muted">{{ __('dpa_admin.col_version') }}</th>
<th class="px-6 py-3 font-medium text-muted">{{ __('dpa_admin.col_state') }}</th>
<th class="px-6 py-3 font-medium text-muted">{{ __('dpa_admin.col_accepted') }}</th>
<th class="px-6 py-3 font-medium text-muted">{{ __('dpa_admin.col_files') }}</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody>
@foreach ($versions as $v)
<tr wire:key="dpa-{{ $v->uuid }}" class="border-b border-line last:border-0">
<td class="px-6 py-3">
<span class="font-mono font-semibold text-ink">{{ $v->version }}</span>
@if ($v->note)
<span class="mt-0.5 block text-xs text-muted">{{ $v->note }}</span>
@endif
</td>
<td class="px-6 py-3">
@if ($v->isPublished())
<x-ui.badge status="{{ $current?->id === $v->id ? 'success' : 'neutral' }}">
{{ $current?->id === $v->id ? __('dpa_admin.state_current') : __('dpa_admin.state_superseded') }}
</x-ui.badge>
<span class="mt-0.5 block text-xs text-muted">{{ $v->published_at->local()->isoFormat('LL') }}</span>
@else
<x-ui.badge status="warning">{{ __('dpa_admin.state_draft') }}</x-ui.badge>
@endif
</td>
<td class="px-6 py-3 font-mono tabular-nums text-body">{{ $v->acceptances_count }}</td>
<td class="px-6 py-3">
<div class="flex flex-wrap gap-2">
<a href="{{ route('admin.dpa.file', ['uuid' => $v->uuid, 'which' => 'agreement']) }}"
target="_blank" rel="noopener"
class="text-xs font-semibold text-accent-text underline-offset-4 hover:underline">
{{ __('dpa_admin.file_agreement') }}
</a>
@if ($v->measures_path)
<a href="{{ route('admin.dpa.file', ['uuid' => $v->uuid, 'which' => 'measures']) }}"
target="_blank" rel="noopener"
class="text-xs font-semibold text-accent-text underline-offset-4 hover:underline">
{{ __('dpa_admin.file_measures') }}
</a>
@endif
{{-- A download, so it says so and looks like
a control rather than a third link in
a row of two. --}}
<a href="{{ route('admin.dpa.file', ['uuid' => $v->uuid, 'which' => 'agreement', 'download' => 1]) }}"
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">
<x-ui.icon name="download" class="size-3.5" />{{ __('dpa_admin.download') }}
</a>
</div>
</td>
<td class="px-6 py-3 text-right">
@if (! $v->isPublished())
{{-- R23: a modal, because publishing leaves every
customer outstanding again. --}}
<x-ui.button size="sm" variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-publish-dpa', arguments: { uuid: '{{ $v->uuid }}' } })">
{{ __('dpa_admin.publish') }}
</x-ui.button>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>

View File

@ -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
<div class="space-y-6">
<header class="animate-rise">
@ -23,352 +51,495 @@
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('settings.subtitle') }}</p>
</header>
{{-- The same tab bar the console uses, so one vocabulary holds on both sides
of the login. --}}
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
@foreach ($tabs as $name)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
@class([
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
'border-accent-active text-ink' => $tab === $name,
'border-transparent text-muted hover:text-ink' => $tab !== $name,
])>
<x-ui.icon :name="['profile' => 'users', 'security' => 'shield-check', 'branding' => 'pen', 'contract' => 'receipt'][$name] ?? 'settings'"
class="size-4" />{{ __('settings.tab.'.$name) }}
</button>
@endforeach
</div>
<div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-8">
{{-- ── Ihre Daten ───────────────────────────────────────────────────── --}}
@if ($tab === 'profile')
<form wire:submit="saveProfile" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('settings.company_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.company_sub') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
<x-ui.input name="contactName" wire:model="contactName" :label="__('settings.contact_name')" />
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
</div>
{{-- 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. --}}
<div class="space-y-4">
<p class="text-sm font-medium text-body">{{ __('settings.billing_address') }}</p>
<x-ui.input name="billingStreet" wire:model="billingStreet" :label="__('settings.billing_street')" />
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :label="__('settings.billing_postcode')" />
<x-ui.input name="billingCity" wire:model="billingCity" :label="__('settings.billing_city')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
</div>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">{{ __('settings.save') }}</x-ui.button>
</div>
{{-- ── 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. --}}
<nav class="-mx-4 shrink-0 overflow-x-auto px-4 lg:mx-0 lg:w-[232px] lg:overflow-visible lg:px-0 lg:sticky lg:top-8 animate-rise [animation-delay:40ms]"
role="tablist" aria-label="{{ __('settings.title') }}">
<div class="flex gap-1.5 lg:flex-col">
@foreach ($sections as $name => $icon)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
@class([
'flex shrink-0 items-center gap-2.5 whitespace-nowrap rounded px-3 py-2 text-sm font-medium transition-colors lg:w-full',
'bg-surface text-ink shadow-xs ring-1 ring-line' => $tab === $name,
'text-muted hover:bg-surface-hover hover:text-ink' => $tab !== $name,
])>
<x-ui.icon :name="$icon" @class(['size-4', 'text-accent-text' => $tab === $name]) />
{{ __('settings.tab.'.$name) }}
</button>
@endforeach
</div>
{{-- Beside the form, and for a record that has one it is a FACT
rather than a field. 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 the question; this page reports
the answer. 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.
A record created before the question existed has no answer yet,
and may be given one here, once. --}}
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
<p class="text-sm font-bold text-ink">{{ __('settings.customer_type') }}</p>
@if ($customer?->customer_type)
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
<x-ui.icon name="lock" class="size-4 text-muted" />
{{ __('settings.customer_type_'.$customer->customer_type) }}
</p>
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_locked') }}</p>
@else
<div class="space-y-2">
@foreach ($customerTypes as $type)
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="customerType" value="{{ $type }}"
class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.customer_type_'.$type) }}
</label>
@endforeach
</div>
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_once') }}</p>
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
@endif
{{-- Who is signed in, at the foot of the list. A settings page is one
of the few where "whose settings are these" is a real question
an office with a shared machine has it every day. --}}
<div class="mt-6 hidden rounded border border-line bg-surface-2 px-3 py-3 lg:block">
<p class="lbl">{{ __('settings.signed_in_as') }}</p>
<p class="mt-1.5 truncate text-sm font-medium text-ink">{{ auth()->user()->name }}</p>
<p class="truncate font-mono text-xs text-muted">{{ auth()->user()->email }}</p>
</div>
</form>
@endif
</nav>
{{-- ── Sicherheit ───────────────────────────────────────────────────── --}}
@if ($tab === 'security')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
{{-- ── The content column ───────────────────────────────────────────
820px, not the whole shell. Nobody types a postcode into a field a
thousand pixels wide. --}}
<div class="min-w-0 flex-1 lg:max-w-[820px]">
{{-- Own password. There was no way to change one at all Fortify's
updatePasswords feature was switched off. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
</div>
{{-- ══ Ihre Daten ═══════════════════════════════════════════════ --}}
@if ($tab === 'profile')
<form wire:submit="saveProfile" class="space-y-8 animate-rise [animation-delay:60ms]">
<section>
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.company_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.company_sub') }}</p>
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
<div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
:label="__('admin_settings.password_new')" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
<x-ui.panel class="mt-4">
<x-ui.row :label="__('settings.company_name')" for="companyName">
<x-ui.input name="companyName" wire:model="companyName" />
</x-ui.row>
<x-ui.row :label="__('settings.contact_name')" for="contactName">
<x-ui.input name="contactName" wire:model="contactName" />
</x-ui.row>
<x-ui.row :label="__('settings.phone')" for="phone">
<x-ui.input name="phone" wire:model="phone" />
</x-ui.row>
<x-ui.row :label="__('settings.vat_id')" for="vatId">
<x-ui.input name="vatId" wire:model="vatId" />
</x-ui.row>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
</div>
</form>
{{-- 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. --}}
<x-ui.row :label="__('settings.billing_address')" for="billingStreet">
<div class="space-y-3">
<x-ui.input name="billingStreet" wire:model="billingStreet" :placeholder="__('settings.billing_street')" />
<div class="grid gap-3 sm:grid-cols-[130px_minmax(0,1fr)]">
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :placeholder="__('settings.billing_postcode')" />
<x-ui.input name="billingCity" wire:model="billingCity" :placeholder="__('settings.billing_city')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :placeholder="__('settings.billing_country')" />
</div>
</x-ui.row>
{{-- 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. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-bold text-ink">{{ __('settings.twofa_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
</span>
</div>
<p class="mt-1.5 text-sm text-muted">{{ __('settings.twofa_sub') }}</p>
</div>
<x-slot:footer>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">
{{ __('settings.save') }}
</x-ui.button>
</x-slot:footer>
</x-ui.panel>
</section>
@if (! $passwordConfirmed)
{{-- The gate. A signed-in session is not enough to change how
the account is protected the risk is an unlocked
machine. --}}
<form wire:submit="confirmPassword" class="rounded border border-line bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('settings.twofa_confirm_first') }}</p>
<div class="mt-3 flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('settings.twofa_confirm_button') }}
</x-ui.button>
</div>
</form>
@elseif ($twoFactorOn)
<div class="flex flex-wrap gap-2">
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('settings.twofa_new_codes') }}
</x-ui.button>
<x-ui.button variant="danger"
x-on:click="$dispatch('openModal', { component: 'confirm-disable-two-factor' })">
{{ __('settings.twofa_disable') }}
</x-ui.button>
</div>
@elseif ($twoFactorPending)
<div class="grid gap-5 sm:grid-cols-[auto_1fr] sm:items-start">
<div class="rounded border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
<form wire:submit="confirmTwoFactor" class="space-y-3">
<p class="text-sm text-muted">{{ __('settings.twofa_scan') }}</p>
<div class="max-w-48">
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
{{ __('settings.twofa_activate') }}
</x-ui.button>
</form>
</div>
@else
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
{{ __('settings.twofa_enable') }}
</x-ui.button>
@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. --}}
<div class="rounded border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-medium text-warning">{{ __('settings.twofa_codes_title') }}</p>
<p class="mt-1 text-xs text-warning">{{ __('settings.twofa_codes_hint') }}</p>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-sm text-ink">
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
</ul>
</div>
@endif
</div>
{{-- Under both, across the width: it answers the same question they
do who can get into this account and it is a table. --}}
<div class="lg:col-span-2">
@livewire('sessions')
</div>
</div>
@endif
{{-- ── Erscheinungsbild ─────────────────────────────────────────────── --}}
@if ($tab === 'branding')
<form wire:submit="saveBranding" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('settings.branding_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
</div>
<x-ui.input name="brandDisplayName" wire:model="brandDisplayName" :label="__('settings.brand_display_name')" :placeholder="$branding['display_name'] ?? ''" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_primary') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input type="color" wire:model="brandPrimary" value="{{ $brandPrimary ?: ($branding['primary_color'] ?? '#f97316') }}" class="h-9 w-12 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_primary') }}" />
<input type="text" wire:model="brandPrimary" placeholder="{{ $branding['primary_color'] ?? '#f97316' }}" class="w-28 rounded border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-sm text-ink" />
</div>
@error('brandPrimary')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_accent') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input type="color" wire:model="brandAccent" value="{{ $brandAccent ?: ($branding['accent_color'] ?? '#c2560a') }}" class="h-9 w-12 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_accent') }}" />
<input type="text" wire:model="brandAccent" placeholder="{{ $branding['accent_color'] ?? '#c2560a' }}" class="w-28 rounded border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-sm text-ink" />
</div>
@error('brandAccent')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">{{ __('settings.save') }}</x-ui.button>
</div>
</div>
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
<label class="text-sm font-bold text-ink">{{ __('settings.brand_logo') }}</label>
<div class="grid size-20 place-items-center overflow-hidden rounded border border-line bg-surface-2">
@if ($logo)
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-20 max-w-20 object-contain" />
@elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-20 max-w-20 object-contain" />
@else
<span class="px-2 text-center text-xs text-muted">{{ __('settings.brand_default') }}</span>
@endif
</div>
<input type="file" wire:model="logo" accept="image/png,image/webp" class="w-full text-sm text-body file:mr-3 file:rounded file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
<p class="text-xs leading-relaxed text-muted">{{ __('settings.brand_logo_hint') }}</p>
@if ($logoUrl)
<button type="button" wire:click="removeLogo" class="text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif
@error('logo')<p class="text-xs text-danger">{{ $message }}</p>@enderror
@if ($branding['is_default'] ?? false)
<p class="border-t border-line pt-3 text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
@endif
</div>
</form>
@endif
{{-- ── Vertrag ──────────────────────────────────────────────────────── --}}
@if ($tab === 'contract')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
@if ($cancellationScheduled)
<div class="flex items-start gap-3 rounded border border-warning-border bg-warning-bg p-4">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
<div>
<p class="text-sm font-semibold text-ink">{{ __('settings.cancel_scheduled_title') }}</p>
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
</div>
</div>
@elseif ($hasActivePackage)
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted">{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}</p>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
{{ __('settings.cancel_cta') }}
</x-ui.button>
</div>
@else
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
@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)
<div class="border-t border-line pt-4">
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
<p class="mt-0.5 text-sm text-muted">
{{ __('withdrawal.card_sub', [
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
'days' => $withdrawal->daysLeft(),
]) }}
{{-- 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. --}}
<section>
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.customer_type') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">
{{ $customer?->customer_type ? __('settings.customer_type_locked') : __('settings.customer_type_once') }}
</p>
<x-ui.button variant="secondary" size="sm" class="mt-3"
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
{{ __('withdrawal.cta') }}
</x-ui.button>
</div>
@endif
</div>
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('settings.close_account_title') }}</h2>
<p class="mt-0.5 text-sm text-muted">{{ __('settings.close_account_sub') }}</p>
</div>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
{{ __('settings.close_cta') }}
</x-ui.button>
</div>
<x-ui.panel class="mt-4">
<div class="px-6 py-4">
@if ($customer?->customer_type)
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
<x-ui.icon name="lock" class="size-4 text-muted" />
{{ __('settings.customer_type_'.$customer->customer_type) }}
</p>
@else
<div class="flex flex-wrap gap-2">
@foreach ($customerTypes as $type)
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="customerType" value="{{ $type }}"
class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.customer_type_'.$type) }}
</label>
@endforeach
</div>
@error('customerType')<p class="mt-2 text-xs text-danger">{{ $message }}</p>@enderror
@endif
</div>
</x-ui.panel>
</section>
</form>
@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. --}}
<div class="rounded border border-line bg-surface-2 p-4">
<p class="text-sm font-semibold text-ink">{{ __('settings.lifecycle_title') }}</p>
<ul class="mt-2 space-y-1.5 text-sm leading-relaxed text-muted">
<li>{{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}</li>
<li>{{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}</li>
<li>{{ __('settings.lifecycle_kept') }}</li>
</ul>
<a href="{{ route('legal.agb') }}" target="_blank" rel="noopener"
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
{{ __('settings.lifecycle_terms') }}
</a>
{{-- ══ Sicherheit ═══════════════════════════════════════════════ --}}
@if ($tab === 'security')
<div class="space-y-8 animate-rise [animation-delay:60ms]">
{{-- Own password. There was no way to change one at all
Fortify's updatePasswords feature was switched off. --}}
<section>
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('admin_settings.password_sub') }}</p>
<form wire:submit="updateOwnPassword">
<x-ui.panel class="mt-4">
<x-ui.row :label="__('admin_settings.password_current')" for="currentPassword">
<x-ui.input name="currentPassword" type="password" autocomplete="current-password" wire:model="currentPassword" />
</x-ui.row>
<x-ui.row :label="__('admin_settings.password_new')" for="newPassword">
<div class="space-y-3">
<x-ui.input name="newPassword" type="password" autocomplete="new-password" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:placeholder="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
</x-ui.row>
<x-slot:footer>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">
{{ __('admin_settings.password_save') }}
</x-ui.button>
</x-slot:footer>
</x-ui.panel>
</form>
</section>
{{-- 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. --}}
<section>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.twofa_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
</span>
</div>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.twofa_sub') }}</p>
<x-ui.panel class="mt-4">
@if (! $passwordConfirmed)
{{-- The gate. A signed-in session is not enough
to change how the account is protected the
risk is an unlocked machine. --}}
<form wire:submit="confirmPassword">
<x-ui.row :label="__('admin_settings.password_current')" :hint="__('settings.twofa_confirm_first')" for="confirmablePassword">
<div class="flex flex-wrap items-start gap-2">
<div class="min-w-48 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="secondary" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('settings.twofa_confirm_button') }}
</x-ui.button>
</div>
</x-ui.row>
</form>
@elseif ($twoFactorOn)
<x-ui.row :label="__('settings.twofa_new_codes')" :hint="__('settings.twofa_codes_hint')">
<div class="flex flex-wrap gap-2">
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('settings.twofa_new_codes') }}
</x-ui.button>
<x-ui.button variant="danger"
x-on:click="$dispatch('openModal', { component: 'confirm-disable-two-factor' })">
{{ __('settings.twofa_disable') }}
</x-ui.button>
</div>
</x-ui.row>
@elseif ($twoFactorPending)
<x-ui.row :label="__('settings.twofa_scan')">
<div class="flex flex-wrap items-start gap-5">
<div class="rounded border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
<form wire:submit="confirmTwoFactor" class="space-y-3">
<div class="max-w-44">
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
{{ __('settings.twofa_activate') }}
</x-ui.button>
</form>
</div>
</x-ui.row>
@else
<x-ui.row :label="__('settings.twofa_enable')" :hint="__('settings.twofa_sub')">
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
{{ __('settings.twofa_enable') }}
</x-ui.button>
</x-ui.row>
@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. --}}
<x-ui.row :label="__('settings.twofa_codes_title')" :hint="__('settings.twofa_codes_hint')">
<ul class="grid grid-cols-2 gap-x-6 gap-y-1 rounded border border-warning-border bg-warning-bg p-4 font-mono text-sm text-ink">
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
</ul>
</x-ui.row>
@endif
</x-ui.panel>
</section>
{{-- The devices. It answers the same question two-factor does
who can get into this account. --}}
<section>
@livewire('sessions')
</section>
</div>
</div>
@endif
{{-- ══ Erscheinungsbild ═════════════════════════════════════════ --}}
@if ($tab === 'branding')
<form wire:submit="saveBranding" class="animate-rise [animation-delay:60ms]">
<section>
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.branding_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.branding_sub') }}</p>
<x-ui.panel class="mt-4">
<x-ui.row :label="__('settings.brand_display_name')" for="brandDisplayName">
<x-ui.input name="brandDisplayName" wire:model="brandDisplayName" :placeholder="$branding['display_name'] ?? ''" />
</x-ui.row>
<x-ui.row :label="__('settings.brand_primary')">
<div class="flex items-center gap-2">
<input type="color" wire:model="brandPrimary" value="{{ $brandPrimary ?: ($branding['primary_color'] ?? '#f97316') }}"
class="h-10 w-14 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_primary') }}" />
<input type="text" wire:model="brandPrimary" placeholder="{{ $branding['primary_color'] ?? '#f97316' }}"
class="w-32 rounded border border-line-strong bg-surface px-3 py-2.5 font-mono text-sm text-ink" />
</div>
@error('brandPrimary')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</x-ui.row>
<x-ui.row :label="__('settings.brand_accent')">
<div class="flex items-center gap-2">
<input type="color" wire:model="brandAccent" value="{{ $brandAccent ?: ($branding['accent_color'] ?? '#c2560a') }}"
class="h-10 w-14 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_accent') }}" />
<input type="text" wire:model="brandAccent" placeholder="{{ $branding['accent_color'] ?? '#c2560a' }}"
class="w-32 rounded border border-line-strong bg-surface px-3 py-2.5 font-mono text-sm text-ink" />
</div>
@error('brandAccent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</x-ui.row>
<x-ui.row :label="__('settings.brand_logo')" :hint="__('settings.brand_logo_hint')">
<div class="flex flex-wrap items-center gap-4">
<div class="grid size-16 shrink-0 place-items-center overflow-hidden rounded border border-line bg-surface-2">
@if ($logo)
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-16 max-w-16 object-contain" />
@elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
@else
<span class="px-2 text-center text-[10px] leading-tight text-muted">{{ __('settings.brand_default') }}</span>
@endif
</div>
<div class="min-w-0 flex-1 space-y-2">
<input type="file" wire:model="logo" accept="image/png,image/webp"
class="w-full text-sm text-body file:mr-3 file:rounded file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
@if ($logoUrl)
<button type="button" wire:click="removeLogo" class="text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif
@error('logo')<p class="text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
</x-ui.row>
<x-slot:footer>
@if ($branding['is_default'] ?? false)
<p class="mr-auto text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
@endif
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">
{{ __('settings.save') }}
</x-ui.button>
</x-slot:footer>
</x-ui.panel>
</section>
</form>
@endif
{{-- ══ Vertrag ══════════════════════════════════════════════════ --}}
@if ($tab === 'contract')
<div class="space-y-8 animate-rise [animation-delay:60ms]">
<section>
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.package_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.contract_sub') }}</p>
<x-ui.panel class="mt-4">
<x-ui.row :label="__('settings.package_title')">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="min-w-0 text-sm text-body">
@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
</p>
@if ($hasActivePackage && ! $cancellationScheduled)
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
{{ __('settings.cancel_cta') }}
</x-ui.button>
@elseif (! $hasActivePackage && ! $cancellationScheduled)
<x-ui.button :href="route('order')" variant="secondary" size="sm" wire:navigate>
{{ __('settings.to_packages') }}
</x-ui.button>
@endif
</div>
</x-ui.row>
{{-- ── 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)
<x-ui.row :label="__('withdrawal.card_title')">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="min-w-0 max-w-[60ch] text-sm text-body">
@if ($withdrawal->open)
{{ __('withdrawal.card_sub', [
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
'days' => $withdrawal->daysLeft(),
]) }}
@else
{{ $withdrawal->refusal }}
@endif
</p>
@if ($withdrawal->open)
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
{{ __('withdrawal.cta') }}
</x-ui.button>
@endif
</div>
</x-ui.row>
@endif
</x-ui.panel>
</section>
{{-- ── 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)
<section>
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('dpa.title') }}</h2>
<span class="font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span>
</div>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('dpa.sub') }}</p>
<x-ui.panel class="mt-4">
<x-ui.row :label="__('dpa.read_agreement')">
<div class="flex flex-wrap gap-2">
<x-ui.button :href="route('dpa.file', 'agreement')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="file-text" class="size-4" />{{ __('dpa.open') }}
</x-ui.button>
<x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
</x-ui.button>
</div>
</x-ui.row>
@if ($dpa->measures_path)
<x-ui.row :label="__('dpa.read_measures')">
<div class="flex flex-wrap gap-2">
<x-ui.button :href="route('dpa.file', 'measures')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="shield" class="size-4" />{{ __('dpa.open') }}
</x-ui.button>
<x-ui.button :href="route('dpa.file', ['which' => 'measures', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
</x-ui.button>
</div>
</x-ui.row>
@endif
<x-ui.row :label="__('dpa.state_label')">
@if ($dpaAcceptance)
{{-- R19: stored in UTC, read on the wall clock. --}}
<p class="flex items-start gap-2 text-sm leading-relaxed text-body">
<x-ui.icon name="check" class="mt-0.5 size-4 shrink-0 text-success" />
{{ __('dpa.accepted_on', [
'version' => $dpa->version,
'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'),
]) }}
</p>
@else
<div class="space-y-3">
<p class="max-w-[60ch] text-sm leading-relaxed text-body">{{ __('dpa.in_force_note') }}</p>
<x-ui.button variant="secondary" size="sm"
wire:click="acceptProcessingAgreement" wire:loading.attr="disabled" wire:target="acceptProcessingAgreement">
{{ __('dpa.accept_cta') }}
</x-ui.button>
</div>
@endif
</x-ui.row>
</x-ui.panel>
</section>
@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. --}}
<section>
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.close_account_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.close_account_sub') }}</p>
<x-ui.panel class="mt-4 border-danger-border">
<x-ui.row :label="__('settings.lifecycle_title')">
<ul class="space-y-1.5 text-sm leading-relaxed text-muted">
<li>{{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}</li>
<li>{{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}</li>
<li>{{ __('settings.lifecycle_kept') }}</li>
</ul>
<a href="{{ route('legal.agb') }}" target="_blank" rel="noopener"
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
{{ __('settings.lifecycle_terms') }}
</a>
</x-ui.row>
<x-ui.row :label="__('settings.close_cta')" :hint="__('settings.close_account_sub')">
<div class="flex justify-start sm:justify-end">
<x-ui.button variant="danger" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
{{ __('settings.close_cta') }}
</x-ui.button>
</div>
</x-ui.row>
</x-ui.panel>
</section>
</div>
@endif
</div>
@endif
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,345 @@
<?php
use App\Livewire\Admin\ProcessingAgreements;
use App\Livewire\Settings;
use App\Models\Customer;
use App\Models\DpaAcceptance;
use App\Models\DpaVersion;
use App\Models\User;
use App\Services\Legal\ProcessingAgreement;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
/**
* The processing agreement, and the proof a customer has it.
*
* 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 for it.
*
* 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
{
$user = User::factory()->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');
});

View File

@ -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, '<x-ui.panel'))->toBeGreaterThanOrEqual(5)
->and(substr_count($page, '<x-ui.row'))->toBeGreaterThanOrEqual(12)
// No hand-rolled card markup left behind.
->and($page)->not->toContain('rounded-lg border border-line bg-surface p-6 shadow-xs');
});

View File

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