Merge branch 'main' into claude/cool-sammet-368fee
commit
ecfced9257
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Services\Billing\CustomDomainAccess;
|
use App\Services\Billing\CustomDomainAccess;
|
||||||
use App\Services\Billing\WithdrawalRight;
|
use App\Services\Billing\WithdrawalRight;
|
||||||
|
use App\Services\Legal\ProcessingAgreement;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
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
|
* 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.
|
* 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')]
|
#[On('withdrawal-confirmed')]
|
||||||
public function withdraw(): void
|
public function withdraw(): void
|
||||||
{
|
{
|
||||||
|
|
@ -482,6 +509,11 @@ class Settings extends Component
|
||||||
// thing somebody can choose. An unrecorded customer sees neither
|
// thing somebody can choose. An unrecorded customer sees neither
|
||||||
// selected, which is the truth about their record.
|
// selected, which is the truth about their record.
|
||||||
'customerTypes' => Customer::TYPES,
|
'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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,10 @@ final class Navigation
|
||||||
// it is sent from, not under Betrieb: both are about how mail
|
// it is sent from, not under Betrieb: both are about how mail
|
||||||
// leaves this installation.
|
// leaves this installation.
|
||||||
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
|
['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
|
// System, not Betrieb: the tunnel is how the console reaches
|
||||||
// the estate at all. Nothing is provisioned, monitored or
|
// the estate at all. Nothing is provisioned, monitored or
|
||||||
// updated through anything else, so it belongs beside the other
|
// updated through anything else, so it belongs beside the other
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -11,6 +11,7 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'nav' => [
|
'nav' => [
|
||||||
|
'dpa' => 'AV-Vertrag',
|
||||||
'mail_preview' => 'E-Mail-Vorschau',
|
'mail_preview' => 'E-Mail-Vorschau',
|
||||||
'inbox' => 'Posteingang',
|
'inbox' => 'Posteingang',
|
||||||
'mail_log' => 'Postausgang',
|
'mail_log' => 'Postausgang',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -11,6 +11,7 @@ return [
|
||||||
'branding' => 'Erscheinungsbild',
|
'branding' => 'Erscheinungsbild',
|
||||||
'contract' => 'Vertrag',
|
'contract' => 'Vertrag',
|
||||||
],
|
],
|
||||||
|
'signed_in_as' => 'Angemeldet als',
|
||||||
'title' => 'Einstellungen',
|
'title' => 'Einstellungen',
|
||||||
'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.',
|
'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.',
|
||||||
'save' => 'Speichern',
|
'save' => 'Speichern',
|
||||||
|
|
@ -49,6 +50,9 @@ return [
|
||||||
'brand_using_default' => 'Es werden aktuell die CluPilot-Standardwerte verwendet.',
|
'brand_using_default' => 'Es werden aktuell die CluPilot-Standardwerte verwendet.',
|
||||||
'branding_saved' => 'Branding gespeichert.',
|
'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_title' => 'Paket',
|
||||||
'package_active' => 'Aktives Paket: :plan.',
|
'package_active' => 'Aktives Paket: :plan.',
|
||||||
'no_package' => 'Kein aktives Paket.',
|
'no_package' => 'Kein aktives Paket.',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'nav' => [
|
'nav' => [
|
||||||
|
'dpa' => 'Processing agreement',
|
||||||
'mail_preview' => 'Mail preview',
|
'mail_preview' => 'Mail preview',
|
||||||
'inbox' => 'Inbox',
|
'inbox' => 'Inbox',
|
||||||
'mail_log' => 'Mail sent',
|
'mail_log' => 'Mail sent',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -11,6 +11,7 @@ return [
|
||||||
'branding' => 'Appearance',
|
'branding' => 'Appearance',
|
||||||
'contract' => 'Contract',
|
'contract' => 'Contract',
|
||||||
],
|
],
|
||||||
|
'signed_in_as' => 'Signed in as',
|
||||||
'title' => 'Settings',
|
'title' => 'Settings',
|
||||||
'subtitle' => 'Everything that belongs to your account, grouped by subject.',
|
'subtitle' => 'Everything that belongs to your account, grouped by subject.',
|
||||||
'save' => 'Save',
|
'save' => 'Save',
|
||||||
|
|
@ -49,6 +50,9 @@ return [
|
||||||
'brand_using_default' => 'Currently using the CluPilot defaults.',
|
'brand_using_default' => 'Currently using the CluPilot defaults.',
|
||||||
'branding_saved' => 'Branding saved.',
|
'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_title' => 'Package',
|
||||||
'package_active' => 'Active package: :plan.',
|
'package_active' => 'Active package: :plan.',
|
||||||
'no_package' => 'No active package.',
|
'no_package' => 'No active package.',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -203,8 +203,12 @@
|
||||||
<p class="text-md leading-relaxed text-body">
|
<p class="text-md leading-relaxed text-body">
|
||||||
Welche Daten CluPilot zu welchem Zweck verarbeitet, steht in der
|
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>.
|
<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
|
Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt der Vertrag über die
|
||||||
Auftragsverarbeitung nach Art. 28 DSGVO, den der Kunde anfordern kann.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,18 +1,46 @@
|
||||||
{{--
|
{{--
|
||||||
The customer's own settings.
|
The customer's own settings.
|
||||||
|
|
||||||
It was one column 768 pixels wide inside a 1240-pixel shell — a third of the
|
## Four attempts, and what was wrong with each
|
||||||
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 tabs, by what somebody came to do. Inside a tab the short cards sit side
|
1. One column 768px wide in a 1240px shell, everything in it, two thousand
|
||||||
by side instead of each taking a full row to hold three lines. The choice is
|
pixels deep.
|
||||||
in the query string (see the #[Url] attribute on the component), so a reload,
|
2. Four tabs — better — but cards of different heights in a grid, which on
|
||||||
a bookmark and the back button all land where they were.
|
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">
|
<div class="space-y-6">
|
||||||
|
|
||||||
<header class="animate-rise">
|
<header class="animate-rise">
|
||||||
|
|
@ -23,82 +51,113 @@
|
||||||
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('settings.subtitle') }}</p>
|
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('settings.subtitle') }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{-- The same tab bar the console uses, so one vocabulary holds on both sides
|
<div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-8">
|
||||||
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">
|
{{-- ── The section list ────────────────────────────────────────────
|
||||||
@foreach ($tabs as $name)
|
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 }}')"
|
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
|
||||||
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
|
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
|
||||||
@class([
|
@class([
|
||||||
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
|
'flex shrink-0 items-center gap-2.5 whitespace-nowrap rounded px-3 py-2 text-sm font-medium transition-colors lg:w-full',
|
||||||
'border-accent-active text-ink' => $tab === $name,
|
'bg-surface text-ink shadow-xs ring-1 ring-line' => $tab === $name,
|
||||||
'border-transparent text-muted hover:text-ink' => $tab !== $name,
|
'text-muted hover:bg-surface-hover hover:text-ink' => $tab !== $name,
|
||||||
])>
|
])>
|
||||||
<x-ui.icon :name="['profile' => 'users', 'security' => 'shield-check', 'branding' => 'pen', 'contract' => 'receipt'][$name] ?? 'settings'"
|
<x-ui.icon :name="$icon" @class(['size-4', 'text-accent-text' => $tab === $name]) />
|
||||||
class="size-4" />{{ __('settings.tab.'.$name) }}
|
{{ __('settings.tab.'.$name) }}
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ── Ihre Daten ───────────────────────────────────────────────────── --}}
|
{{-- 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>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{-- ── 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]">
|
||||||
|
|
||||||
|
{{-- ══ Ihre Daten ═══════════════════════════════════════════════ --}}
|
||||||
@if ($tab === 'profile')
|
@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]">
|
<form wire:submit="saveProfile" class="space-y-8 animate-rise [animation-delay:60ms]">
|
||||||
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
<section>
|
||||||
<div>
|
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.company_title') }}</h2>
|
||||||
<h2 class="text-sm font-bold text-ink">{{ __('settings.company_title') }}</h2>
|
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.company_sub') }}</p>
|
||||||
<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.panel class="mt-4">
|
||||||
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
|
<x-ui.row :label="__('settings.company_name')" for="companyName">
|
||||||
<x-ui.input name="contactName" wire:model="contactName" :label="__('settings.contact_name')" />
|
<x-ui.input name="companyName" wire:model="companyName" />
|
||||||
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
|
</x-ui.row>
|
||||||
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
|
<x-ui.row :label="__('settings.contact_name')" for="contactName">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
{{-- Field by field, not a paragraph: what landed in the
|
{{-- Field by field, not a paragraph: what landed in
|
||||||
textarea was whatever somebody typed — a postcode on the
|
the textarea was whatever somebody typed — a
|
||||||
street line, a city with no postcode, no country at all —
|
postcode on the street line, a town with no
|
||||||
and an invoice has to name its recipient precisely enough to
|
postcode, no country at all — and an invoice has
|
||||||
be a document. Postcode and town share a row because that is
|
to name its recipient precisely enough to be a
|
||||||
how they are read and written. --}}
|
document. --}}
|
||||||
<div class="space-y-4">
|
<x-ui.row :label="__('settings.billing_address')" for="billingStreet">
|
||||||
<p class="text-sm font-medium text-body">{{ __('settings.billing_address') }}</p>
|
<div class="space-y-3">
|
||||||
<x-ui.input name="billingStreet" wire:model="billingStreet" :label="__('settings.billing_street')" />
|
<x-ui.input name="billingStreet" wire:model="billingStreet" :placeholder="__('settings.billing_street')" />
|
||||||
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
|
<div class="grid gap-3 sm:grid-cols-[130px_minmax(0,1fr)]">
|
||||||
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :label="__('settings.billing_postcode')" />
|
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :placeholder="__('settings.billing_postcode')" />
|
||||||
<x-ui.input name="billingCity" wire:model="billingCity" :label="__('settings.billing_city')" />
|
<x-ui.input name="billingCity" wire:model="billingCity" :placeholder="__('settings.billing_city')" />
|
||||||
</div>
|
</div>
|
||||||
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
|
<x-ui.input name="billingCountry" wire:model="billingCountry" :placeholder="__('settings.billing_country')" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
|
|
||||||
<div class="flex justify-end border-t border-line pt-4">
|
<x-slot:footer>
|
||||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">{{ __('settings.save') }}</x-ui.button>
|
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">
|
||||||
</div>
|
{{ __('settings.save') }}
|
||||||
</div>
|
</x-ui.button>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-ui.panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{-- Beside the form, and for a record that has one it is a FACT
|
{{-- A FACT for a record that has one. It decides the
|
||||||
rather than a field. It decides the fourteen-day right of
|
fourteen-day right of withdrawal: a business able to set
|
||||||
withdrawal: a business able to set itself to "Privatperson" here
|
itself to "Privatperson" here is a business able to
|
||||||
is a business able to withdraw from a contract it may not
|
withdraw from a contract it may not withdraw from.
|
||||||
withdraw from. Registration asks the question; this page reports
|
Registration asks; this page reports. The lock is in
|
||||||
the answer. The lock is in Settings::saveProfile, not here — a
|
Settings::saveProfile, not here — a form that only hides
|
||||||
form that only hides a control has never stopped anybody who can
|
a control has never stopped anybody who can post to
|
||||||
post to /livewire/update.
|
/livewire/update. --}}
|
||||||
|
<section>
|
||||||
A record created before the question existed has no answer yet,
|
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.customer_type') }}</h2>
|
||||||
and may be given one here, once. --}}
|
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">
|
||||||
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
{{ $customer?->customer_type ? __('settings.customer_type_locked') : __('settings.customer_type_once') }}
|
||||||
<p class="text-sm font-bold text-ink">{{ __('settings.customer_type') }}</p>
|
</p>
|
||||||
|
|
||||||
|
<x-ui.panel class="mt-4">
|
||||||
|
<div class="px-6 py-4">
|
||||||
@if ($customer?->customer_type)
|
@if ($customer?->customer_type)
|
||||||
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
|
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||||
<x-ui.icon name="lock" class="size-4 text-muted" />
|
<x-ui.icon name="lock" class="size-4 text-muted" />
|
||||||
{{ __('settings.customer_type_'.$customer->customer_type) }}
|
{{ __('settings.customer_type_'.$customer->customer_type) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_locked') }}</p>
|
|
||||||
@else
|
@else
|
||||||
<div class="space-y-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@foreach ($customerTypes as $type)
|
@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">
|
<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 }}"
|
<input type="radio" wire:model="customerType" value="{{ $type }}"
|
||||||
|
|
@ -107,72 +166,79 @@
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_once') }}</p>
|
@error('customerType')<p class="mt-2 text-xs text-danger">{{ $message }}</p>@enderror
|
||||||
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.panel>
|
||||||
|
</section>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ── Sicherheit ───────────────────────────────────────────────────── --}}
|
{{-- ══ Sicherheit ═══════════════════════════════════════════════ --}}
|
||||||
@if ($tab === 'security')
|
@if ($tab === 'security')
|
||||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
|
<div class="space-y-8 animate-rise [animation-delay:60ms]">
|
||||||
|
|
||||||
{{-- Own password. There was no way to change one at all — Fortify's
|
{{-- Own password. There was no way to change one at all —
|
||||||
updatePasswords feature was switched off. --}}
|
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">
|
<section>
|
||||||
<div>
|
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('admin_settings.password_title') }}</h2>
|
||||||
<h2 class="text-sm font-bold 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>
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
|
<form wire:submit="updateOwnPassword">
|
||||||
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
|
<x-ui.panel class="mt-4">
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<x-ui.row :label="__('admin_settings.password_current')" for="currentPassword">
|
||||||
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
|
<x-ui.input name="currentPassword" type="password" autocomplete="current-password" wire:model="currentPassword" />
|
||||||
:label="__('admin_settings.password_new')" wire:model="newPassword" />
|
</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"
|
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
|
||||||
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
|
:placeholder="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
|
|
||||||
<div class="flex justify-end border-t border-line pt-4">
|
<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-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">
|
||||||
</div>
|
{{ __('admin_settings.password_save') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-ui.panel>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{-- Two-factor. Every action is re-checked server-side against a
|
{{-- Two-factor. Every action is re-checked server-side
|
||||||
recent password confirmation — hiding the buttons stops nobody
|
against a recent password confirmation — hiding the
|
||||||
who can post to /livewire/update. --}}
|
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">
|
<section>
|
||||||
<div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<h2 class="text-sm font-bold text-ink">{{ __('settings.twofa_title') }}</h2>
|
<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
|
<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' }}">
|
{{ $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>
|
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||||
{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
|
{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1.5 text-sm text-muted">{{ __('settings.twofa_sub') }}</p>
|
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.twofa_sub') }}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<x-ui.panel class="mt-4">
|
||||||
@if (! $passwordConfirmed)
|
@if (! $passwordConfirmed)
|
||||||
{{-- The gate. A signed-in session is not enough to change how
|
{{-- The gate. A signed-in session is not enough
|
||||||
the account is protected — the risk is an unlocked
|
to change how the account is protected — the
|
||||||
machine. --}}
|
risk is an unlocked machine. --}}
|
||||||
<form wire:submit="confirmPassword" class="rounded border border-line bg-surface-2 p-4">
|
<form wire:submit="confirmPassword">
|
||||||
<p class="text-sm text-body">{{ __('settings.twofa_confirm_first') }}</p>
|
<x-ui.row :label="__('admin_settings.password_current')" :hint="__('settings.twofa_confirm_first')" for="confirmablePassword">
|
||||||
<div class="mt-3 flex flex-wrap items-start gap-2">
|
<div class="flex flex-wrap items-start gap-2">
|
||||||
<div class="min-w-56 flex-1">
|
<div class="min-w-48 flex-1">
|
||||||
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password" wire:model="confirmablePassword" />
|
||||||
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
|
||||||
</div>
|
</div>
|
||||||
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
<x-ui.button type="submit" variant="secondary" wire:loading.attr="disabled" wire:target="confirmPassword">
|
||||||
{{ __('settings.twofa_confirm_button') }}
|
{{ __('settings.twofa_confirm_button') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
</form>
|
</form>
|
||||||
@elseif ($twoFactorOn)
|
@elseif ($twoFactorOn)
|
||||||
|
<x-ui.row :label="__('settings.twofa_new_codes')" :hint="__('settings.twofa_codes_hint')">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
|
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
|
||||||
{{ __('settings.twofa_new_codes') }}
|
{{ __('settings.twofa_new_codes') }}
|
||||||
|
|
@ -182,12 +248,13 @@
|
||||||
{{ __('settings.twofa_disable') }}
|
{{ __('settings.twofa_disable') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
@elseif ($twoFactorPending)
|
@elseif ($twoFactorPending)
|
||||||
<div class="grid gap-5 sm:grid-cols-[auto_1fr] sm:items-start">
|
<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>
|
<div class="rounded border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
|
||||||
<form wire:submit="confirmTwoFactor" class="space-y-3">
|
<form wire:submit="confirmTwoFactor" class="space-y-3">
|
||||||
<p class="text-sm text-muted">{{ __('settings.twofa_scan') }}</p>
|
<div class="max-w-44">
|
||||||
<div class="max-w-48">
|
|
||||||
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
|
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
|
||||||
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
|
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -196,169 +263,261 @@
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
@else
|
@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">
|
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
||||||
{{ __('settings.twofa_enable') }}
|
{{ __('settings.twofa_enable') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
|
</x-ui.row>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($recoveryCodes)
|
@if ($recoveryCodes)
|
||||||
{{-- Shown once, on purpose: nobody writes down what they were
|
{{-- Shown once, on purpose: nobody writes down
|
||||||
never shown, and these are the way back in when the phone
|
what they were never shown, and these are the
|
||||||
is gone. --}}
|
way back in when the phone is gone. --}}
|
||||||
<div class="rounded border border-warning-border bg-warning-bg p-4">
|
<x-ui.row :label="__('settings.twofa_codes_title')" :hint="__('settings.twofa_codes_hint')">
|
||||||
<p class="text-sm font-medium text-warning">{{ __('settings.twofa_codes_title') }}</p>
|
<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">
|
||||||
<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
|
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</x-ui.row>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-ui.panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{-- Under both, across the width: it answers the same question they
|
{{-- The devices. It answers the same question two-factor does
|
||||||
do — who can get into this account — and it is a table. --}}
|
— who can get into this account. --}}
|
||||||
<div class="lg:col-span-2">
|
<section>
|
||||||
@livewire('sessions')
|
@livewire('sessions')
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ── Erscheinungsbild ─────────────────────────────────────────────── --}}
|
{{-- ══ Erscheinungsbild ═════════════════════════════════════════ --}}
|
||||||
@if ($tab === 'branding')
|
@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]">
|
<form wire:submit="saveBranding" class="animate-rise [animation-delay:60ms]">
|
||||||
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
<section>
|
||||||
<div>
|
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('settings.branding_title') }}</h2>
|
||||||
<h2 class="text-sm font-bold text-ink">{{ __('settings.branding_title') }}</h2>
|
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('settings.branding_sub') }}</p>
|
||||||
<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'] ?? ''" />
|
<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>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<x-ui.row :label="__('settings.brand_primary')">
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm font-medium text-body">{{ __('settings.brand_primary') }}</label>
|
<input type="color" wire:model="brandPrimary" value="{{ $brandPrimary ?: ($branding['primary_color'] ?? '#f97316') }}"
|
||||||
<div class="mt-1.5 flex items-center gap-2">
|
class="h-10 w-14 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_primary') }}" />
|
||||||
<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' }}"
|
||||||
<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" />
|
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 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>
|
||||||
|
@error('brandPrimary')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||||
|
</x-ui.row>
|
||||||
|
|
||||||
<div class="flex justify-end border-t border-line pt-4">
|
<x-ui.row :label="__('settings.brand_accent')">
|
||||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">{{ __('settings.save') }}</x-ui.button>
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
@error('brandAccent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||||
|
</x-ui.row>
|
||||||
|
|
||||||
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
<x-ui.row :label="__('settings.brand_logo')" :hint="__('settings.brand_logo_hint')">
|
||||||
<label class="text-sm font-bold text-ink">{{ __('settings.brand_logo') }}</label>
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<div class="grid size-20 place-items-center overflow-hidden rounded border border-line bg-surface-2">
|
<div class="grid size-16 shrink-0 place-items-center overflow-hidden rounded border border-line bg-surface-2">
|
||||||
@if ($logo)
|
@if ($logo)
|
||||||
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-20 max-w-20 object-contain" />
|
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
||||||
@elseif ($logoUrl)
|
@elseif ($logoUrl)
|
||||||
<img src="{{ $logoUrl }}" alt="" class="max-h-20 max-w-20 object-contain" />
|
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
||||||
@else
|
@else
|
||||||
<span class="px-2 text-center text-xs text-muted">{{ __('settings.brand_default') }}</span>
|
<span class="px-2 text-center text-[10px] leading-tight text-muted">{{ __('settings.brand_default') }}</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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" />
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
<p class="text-xs leading-relaxed text-muted">{{ __('settings.brand_logo_hint') }}</p>
|
<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)
|
@if ($logoUrl)
|
||||||
<button type="button" wire:click="removeLogo" class="text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
|
<button type="button" wire:click="removeLogo" class="text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
|
||||||
@endif
|
@endif
|
||||||
@error('logo')<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
@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>
|
</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>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ── Vertrag ──────────────────────────────────────────────────────── --}}
|
{{-- ══ Vertrag ══════════════════════════════════════════════════ --}}
|
||||||
@if ($tab === 'contract')
|
@if ($tab === 'contract')
|
||||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
|
<div class="space-y-8 animate-rise [animation-delay:60ms]">
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
<section>
|
||||||
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
|
<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>
|
||||||
|
|
||||||
@if ($cancellationScheduled)
|
<x-ui.panel class="mt-4">
|
||||||
<div class="flex items-start gap-3 rounded border border-warning-border bg-warning-bg p-4">
|
<x-ui.row :label="__('settings.package_title')">
|
||||||
<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">
|
<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>
|
<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-ui.button variant="secondary" size="sm"
|
||||||
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
|
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
|
||||||
{{ __('settings.cancel_cta') }}
|
{{ __('settings.cancel_cta') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
</div>
|
@elseif (! $hasActivePackage && ! $cancellationScheduled)
|
||||||
@else
|
<x-ui.button :href="route('order')" variant="secondary" size="sm" wire:navigate>
|
||||||
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
|
{{ __('settings.to_packages') }}
|
||||||
|
</x-ui.button>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
|
|
||||||
{{-- The fourteen-day right of withdrawal. Shown only where it
|
{{-- ── Das Widerrufsrecht ────────────────────────
|
||||||
exists: a business customer has none and never sees this
|
Shown to every CONSUMER with a contract, open or
|
||||||
block — and the server refuses them again in
|
not. It used to render only while the window was
|
||||||
Settings::withdraw(), because a card that is not rendered has
|
open, so once the fourteen days expired the block
|
||||||
never stopped anybody who can post to /livewire/update.
|
vanished — indistinguishable from a feature
|
||||||
|
nobody built.
|
||||||
|
|
||||||
Separate from the cancellation above it, and not a variant of
|
A business never sees it, and Settings::withdraw()
|
||||||
it. A cancellation ends a contract that was validly concluded
|
refuses them again on the server: a card that is
|
||||||
and keeps the term the customer paid for; a withdrawal
|
not rendered has never stopped anybody who can
|
||||||
unwinds the contract itself, ends the service the same day
|
post to /livewire/update.
|
||||||
and sends the money back. --}}
|
|
||||||
@if ($withdrawal->applies && $withdrawal->open)
|
Not a variant of the cancellation above it: that
|
||||||
<div class="border-t border-line pt-4">
|
one ends a contract validly concluded and keeps
|
||||||
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
|
the term already paid for; this one unwinds the
|
||||||
<p class="mt-0.5 text-sm text-muted">
|
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', [
|
{{ __('withdrawal.card_sub', [
|
||||||
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
|
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
|
||||||
'days' => $withdrawal->daysLeft(),
|
'days' => $withdrawal->daysLeft(),
|
||||||
]) }}
|
]) }}
|
||||||
|
@else
|
||||||
|
{{ $withdrawal->refusal }}
|
||||||
|
@endif
|
||||||
</p>
|
</p>
|
||||||
<x-ui.button variant="secondary" size="sm" class="mt-3"
|
@if ($withdrawal->open)
|
||||||
|
<x-ui.button variant="secondary" size="sm"
|
||||||
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
|
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
|
||||||
{{ __('withdrawal.cta') }}
|
{{ __('withdrawal.cta') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
|
@endif
|
||||||
|
</x-ui.panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
{{-- ── AV-Vertrag & TOM ──────────────────────────────────
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
Art. 28 DSGVO wants a contract wherever personal data is
|
||||||
<div class="min-w-0">
|
processed on somebody else's behalf. It is concluded WITH
|
||||||
<h2 class="text-sm font-bold text-ink">{{ __('settings.close_account_title') }}</h2>
|
the terms at checkout (see the AGB), so this is not a
|
||||||
<p class="mt-0.5 text-sm text-muted">{{ __('settings.close_account_sub') }}</p>
|
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>
|
</div>
|
||||||
<x-ui.button variant="secondary" size="sm"
|
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('dpa.sub') }}</p>
|
||||||
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
|
|
||||||
{{ __('settings.close_cta') }}
|
<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>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
</x-ui.row>
|
||||||
|
|
||||||
{{-- The deadlines, said where somebody asks the question.
|
@if ($dpa->measures_path)
|
||||||
"Nach fünf Tagen gelöscht" on the verification page reads as
|
<x-ui.row :label="__('dpa.read_measures')">
|
||||||
if it applied to every account; it applies to a registration
|
<div class="flex flex-wrap gap-2">
|
||||||
nobody confirmed. The other rule — a year without a package —
|
<x-ui.button :href="route('dpa.file', 'measures')" variant="secondary" size="sm" target="_blank">
|
||||||
was nowhere at all. Both come from the commands that enforce
|
<x-ui.icon name="shield" class="size-4" />{{ __('dpa.open') }}
|
||||||
them, so the page cannot drift from what actually happens. --}}
|
</x-ui.button>
|
||||||
<div class="rounded border border-line bg-surface-2 p-4">
|
<x-ui.button :href="route('dpa.file', ['which' => 'measures', 'download' => 1])" variant="ghost" size="sm" download>
|
||||||
<p class="text-sm font-semibold text-ink">{{ __('settings.lifecycle_title') }}</p>
|
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
|
||||||
<ul class="mt-2 space-y-1.5 text-sm leading-relaxed text-muted">
|
</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_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_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}</li>
|
||||||
<li>{{ __('settings.lifecycle_kept') }}</li>
|
<li>{{ __('settings.lifecycle_kept') }}</li>
|
||||||
|
|
@ -367,8 +526,20 @@
|
||||||
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
||||||
{{ __('settings.lifecycle_terms') }}
|
{{ __('settings.lifecycle_terms') }}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</div>
|
</x-ui.row>
|
||||||
|
</x-ui.panel>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,30 @@ Route::get('/mail/preview/{key}', function (string $key) {
|
||||||
// into a console page: this is the document itself.
|
// into a console page: this is the document itself.
|
||||||
return response($mailable->render());
|
return response($mailable->render());
|
||||||
})->name('mail.preview.show');
|
})->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
|
// The answers an operator gives over and over, written once — see
|
||||||
// Admin\MailTemplates.
|
// Admin\MailTemplates.
|
||||||
Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates');
|
Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates');
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,32 @@ $portal = function () {
|
||||||
);
|
);
|
||||||
})->name('invoices.pdf');
|
})->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('/billing', Billing::class)->name('billing');
|
||||||
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
||||||
Route::get('/support', Support::class)->name('support');
|
Route::get('/support', Support::class)->name('support');
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ use Spatie\Permission\Models\Role;
|
||||||
it('moves every permission and role to the operator guard, leaving none behind', function () {
|
it('moves every permission and role to the operator guard, leaving none behind', function () {
|
||||||
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
|
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
|
||||||
->and(Role::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
|
// 17 from the original seed, plus customers.grant_plan,
|
||||||
// instances.restart — the latter created straight onto this guard,
|
// instances.restart and dpa.manage — the later ones created straight
|
||||||
// because since this migration `web` is where a permission goes to
|
// onto this guard, because since this migration `web` is where a
|
||||||
// match nobody at all.
|
// permission goes to match nobody at all.
|
||||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(19)
|
->and(Permission::where('guard_name', 'operator')->count())->toBe(20)
|
||||||
->and(Role::where('guard_name', 'operator')->count())->toBe(6);
|
->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
|
// Nothing mutated: still exactly the pre-migration shape, not "moved and
|
||||||
// then rolled back" — there is nothing here to roll back on a real
|
// 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.
|
// 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(Permission::where('guard_name', 'operator')->count())->toBe(0)
|
||||||
->and(Role::where('guard_name', 'web')->count())->toBe(6)
|
->and(Role::where('guard_name', 'web')->count())->toBe(6)
|
||||||
->and(Role::where('guard_name', 'operator')->count())->toBe(0)
|
->and(Role::where('guard_name', 'operator')->count())->toBe(0)
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -377,3 +377,71 @@ it('never offers a business the withdrawal it does not have', function () {
|
||||||
expect(Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html())
|
expect(Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html())
|
||||||
->not->toContain(__('withdrawal.instead_title'));
|
->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');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -148,13 +148,17 @@ it('draws the wordmark from one definition, not five', function () {
|
||||||
|
|
||||||
$source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname()));
|
$source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname()));
|
||||||
|
|
||||||
// The wordmark is markup around the name; a plain sentence mentioning
|
// The wordmark is markup AROUND the name: the name is the whole content
|
||||||
// the company is not. `>Clu` with NO whitespace after the tag catches
|
// of its element, so what follows it is the next tag and not more
|
||||||
// exactly the lockup forms — the name is the whole content of its
|
// sentence. `>Clu<` is the split form, `>CluPilot` up to a tag is the
|
||||||
// element there. Whitespace used to be allowed, and then the terms page
|
// whole one.
|
||||||
// arrived with a paragraph whose first word is the company name, which
|
//
|
||||||
// is a sentence and not a brand lockup.
|
// Twice now a document has tripped a looser version of this — the terms
|
||||||
if (preg_match('/>Clu(Pilot|<)/', $source)) {
|
// 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());
|
$offenders[] = str_replace(resource_path().'/', '', $file->getPathname());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue