Deliver the processing agreement, and hold the proof it was accepted
Art. 28(3) GDPR wants a contract wherever personal data is processed on somebody else's behalf, which is the whole of what this product does. "In writing" there includes electronic form (Art. 28(9)), so a document the customer can read plus a recorded acceptance is enough — no signature on paper. The website already promises "AV-Vertrag inklusive", which means it has to be obtainable without asking us for it. It was not obtainable at all. **The text is never this application's.** An operator uploads the document their lawyer wrote, names the version, and publishes it; the measures ride along as a second file, because they are an annex to the agreement and "which measures applied when this customer accepted" has to have one answer. Inventing the text here would have been worse than having none. **Uploading and publishing are two acts.** Acceptance is per version, so publishing leaves every customer who accepted the previous one outstanding again — correct, and far too expensive to trigger by dropping a file on a form. It goes through a confirmation modal that says exactly that (R23). **The customer's side** is a card in the contract tab: read the agreement, read the measures, one press to conclude it. What that press records is what makes it evidence rather than a flag — the version, the moment, the address it came from, and the login that pressed. Pressing twice is one agreement (unique index, not a check somebody can forget), and a superseded acceptance is kept rather than overwritten: it was true when it was made, and the history is the point. Nothing renders until a version is in force. A card offering an agreement that does not exist is worse than the silence. The files live on the private disk and are served through routes that check who is asking — an agreement is not a public asset, and a guessable URL to one would be a list of who our customers are. The customer route takes no version parameter: which document applies is ours to say. `dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps the platform running does not thereby decide what every customer is asked to agree to — and a capability written under `web` since the 2026-07-29 move lands in a guard nothing authenticates against, which is how this one first shipped answering 403 to a role that visibly had it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus^2
parent
48581c3978
commit
955f1b874f
|
|
@ -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,129 @@
|
|||
<?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.
|
||||
$version = DpaVersion::query()->create([
|
||||
'version' => $data['version'],
|
||||
'note' => $data['note'] ?: null,
|
||||
'agreement_path' => $this->agreement->store('dpa', 'local'),
|
||||
'measures_path' => $this->measures?->store('dpa', 'local'),
|
||||
'created_by' => Auth::guard('operator')->id(),
|
||||
]);
|
||||
|
||||
$this->reset('version', 'note', 'agreement', 'measures');
|
||||
|
||||
$this->dispatch('notify', message: __('dpa_admin.uploaded', ['version' => $version->version]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a version in force.
|
||||
*
|
||||
* Deliberately separate from the upload: every customer who had accepted the
|
||||
* previous one is outstanding again from this moment, and that is not
|
||||
* something to do by dropping a file on a form.
|
||||
*/
|
||||
#[On('dpa-publish-confirmed')]
|
||||
public function publish(string $uuid): void
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
|
||||
$version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
if ($version->isPublished()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$version->update(['published_at' => now()]);
|
||||
|
||||
$this->dispatch('notify', message: __('dpa_admin.published', ['version' => $version->version]));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
|
||||
$current = app(ProcessingAgreement::class)->current();
|
||||
|
||||
// How many customers are outstanding on the version in force. Counted
|
||||
// against customers who are actually still customers — a closed record
|
||||
// is not somebody we are waiting on.
|
||||
$customers = Customer::query()->whereNull('closed_at')->count();
|
||||
$accepted = $current === null ? 0 : $current->acceptances()->count();
|
||||
|
||||
return view('livewire.admin.processing-agreements', [
|
||||
'versions' => DpaVersion::query()->withCount('acceptances')->latest('id')->get(),
|
||||
'current' => $current,
|
||||
'customers' => $customers,
|
||||
'accepted' => $accepted,
|
||||
'outstanding' => max(0, $customers - $accepted),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Whether a stored file is actually there, for the list. */
|
||||
public function exists(?string $path): bool
|
||||
{
|
||||
return $path !== null && Storage::disk('local')->exists($path);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
|
|||
use App\Models\Customer;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use App\Services\Legal\ProcessingAgreement;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
|
@ -396,6 +397,32 @@ class Settings extends Component
|
|||
* never seen this card can still name the method — so the refusal is made
|
||||
* where the mutation is, once, in the customer's own words.
|
||||
*/
|
||||
/**
|
||||
* Accept the processing agreement in force.
|
||||
*
|
||||
* One press, no dialogue: this is an agreement, not a deletion, and putting
|
||||
* a confirmation in front of it would only make the record harder to obtain.
|
||||
* The evidence — the moment, the address, the login — is taken by the
|
||||
* service, so nothing here can record an acceptance from an address it
|
||||
* invented.
|
||||
*/
|
||||
public function acceptProcessingAgreement(): void
|
||||
{
|
||||
$c = $this->requireCustomer();
|
||||
|
||||
if ($c === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (app(ProcessingAgreement::class)->accept($c) === null) {
|
||||
// Nothing published yet. The card does not render in that case, so
|
||||
// reaching this line means somebody posted to it directly.
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('dpa.accepted_notice'));
|
||||
}
|
||||
|
||||
#[On('withdrawal-confirmed')]
|
||||
public function withdraw(): void
|
||||
{
|
||||
|
|
@ -482,6 +509,11 @@ class Settings extends Component
|
|||
// thing somebody can choose. An unrecorded customer sees neither
|
||||
// selected, which is the truth about their record.
|
||||
'customerTypes' => Customer::TYPES,
|
||||
// The processing agreement in force and this customer's acceptance
|
||||
// of THAT version — an acceptance of a superseded one is history,
|
||||
// not an answer (see ProcessingAgreement).
|
||||
'dpa' => ($dpaService = app(ProcessingAgreement::class))->current(),
|
||||
'dpaAcceptance' => $dpaService->acceptanceOf($c),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
// leaves this installation.
|
||||
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
|
||||
// The processing agreement. Under System with the other things
|
||||
// that are true of the whole installation rather than of one
|
||||
// customer.
|
||||
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
|
||||
// System, not Betrieb: the tunnel is how the console reaches
|
||||
// the estate at all. Nothing is provisioned, monitored or
|
||||
// updated through anything else, so it belongs beside the other
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
'dpa' => 'AV-Vertrag',
|
||||
'mail_preview' => 'E-Mail-Vorschau',
|
||||
'inbox' => 'Posteingang',
|
||||
'mail_log' => 'Postausgang',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<?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.',
|
||||
'state_accepted' => 'Abgeschlossen',
|
||||
'state_open' => 'Offen',
|
||||
'read_agreement' => 'AV-Vertrag ansehen',
|
||||
'read_measures' => 'TOM ansehen',
|
||||
'version' => 'Fassung :version',
|
||||
'accept_hint' => 'Bitte lesen Sie den Vertrag und schließen Sie ihn ab. Wir halten dazu Zeitpunkt, Fassung und IP-Adresse fest; eine Unterschrift auf Papier ist nach Art. 28 Abs. 9 DSGVO nicht nötig.',
|
||||
'accept_cta' => 'Zur Kenntnis genommen und abgeschlossen',
|
||||
'accepted_on' => 'Fassung :version abgeschlossen am :when. Bei einer neuen Fassung melden wir uns und bitten Sie erneut um Ihre Zustimmung.',
|
||||
'accepted_notice' => 'AV-Vertrag abgeschlossen. Sie finden ihn jederzeit hier.',
|
||||
];
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?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 abgeschlossen.',
|
||||
'outstanding' => ':count offen',
|
||||
'versions_title' => 'Fassungen',
|
||||
'no_versions' => 'Noch nichts hochgeladen.',
|
||||
'col_version' => 'Fassung',
|
||||
'col_state' => 'Status',
|
||||
'col_accepted' => 'Abschlüsse',
|
||||
'col_files' => 'Dateien',
|
||||
'state_current' => 'In Kraft',
|
||||
'state_superseded' => 'Abgelöst',
|
||||
'state_draft' => 'Entwurf',
|
||||
'publish' => 'In Kraft setzen',
|
||||
'published' => 'Fassung :version ist in Kraft.',
|
||||
'publish_title' => 'Fassung :version in Kraft setzen?',
|
||||
'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.',
|
||||
];
|
||||
|
|
@ -11,6 +11,7 @@ return [
|
|||
],
|
||||
|
||||
'nav' => [
|
||||
'dpa' => 'Processing agreement',
|
||||
'mail_preview' => 'Mail preview',
|
||||
'inbox' => 'Inbox',
|
||||
'mail_log' => 'Mail sent',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<?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.',
|
||||
'state_accepted' => 'Concluded',
|
||||
'state_open' => 'Outstanding',
|
||||
'read_agreement' => 'Read the agreement',
|
||||
'read_measures' => 'Read the measures',
|
||||
'version' => 'Version :version',
|
||||
'accept_hint' => 'Please read the agreement and conclude it. We record the moment, the version and the IP address; a signature on paper is not required (Art. 28(9) GDPR).',
|
||||
'accept_cta' => 'Read and concluded',
|
||||
'accepted_on' => 'Version :version concluded on :when. When a new version is issued we will tell you and ask again.',
|
||||
'accepted_notice' => 'Agreement concluded. You can find it here at any time.',
|
||||
];
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?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 have concluded it.',
|
||||
'outstanding' => ':count outstanding',
|
||||
'versions_title' => 'Versions',
|
||||
'no_versions' => 'Nothing uploaded yet.',
|
||||
'col_version' => 'Version',
|
||||
'col_state' => 'State',
|
||||
'col_accepted' => 'Conclusions',
|
||||
'col_files' => 'Files',
|
||||
'state_current' => 'In force',
|
||||
'state_superseded' => 'Superseded',
|
||||
'state_draft' => 'Draft',
|
||||
'publish' => 'Put in force',
|
||||
'published' => 'Version :version is in force.',
|
||||
'publish_title' => 'Put version :version in force?',
|
||||
'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.',
|
||||
];
|
||||
|
|
@ -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,151 @@
|
|||
{{--
|
||||
The processing agreement, in the console.
|
||||
|
||||
Upload left, what is in force and who has it right, the versions underneath.
|
||||
Uploading and publishing are two acts on purpose: publishing leaves every
|
||||
customer outstanding again, and that is not something to trigger by dropping
|
||||
a file on a form.
|
||||
--}}
|
||||
<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
|
||||
</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>
|
||||
|
|
@ -338,6 +338,69 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ── AV-Vertrag & TOM ──────────────────────────────────────
|
||||
Art. 28(3) DSGVO wants a contract wherever personal data is
|
||||
processed on somebody else's behalf, which is the whole of what
|
||||
this product does. "In writing" there includes electronic form,
|
||||
so the document plus a recorded acceptance is enough — and the
|
||||
customer needs to be able to READ it without asking us for it,
|
||||
because that is what the website promises.
|
||||
|
||||
Nothing renders until an operator has published a version: a
|
||||
card offering an agreement that does not exist would be worse
|
||||
than the silence. --}}
|
||||
@if ($dpa !== null)
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs lg:col-span-2">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-bold text-ink">{{ __('dpa.title') }}</h2>
|
||||
<p class="mt-0.5 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('dpa.sub') }}</p>
|
||||
</div>
|
||||
@if ($dpaAcceptance)
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
<x-ui.icon name="check" class="size-3.5" />{{ __('dpa.state_accepted') }}
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-3.5" />{{ __('dpa.state_open') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center 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.read_agreement') }}
|
||||
</x-ui.button>
|
||||
@if ($dpa->measures_path)
|
||||
<x-ui.button :href="route('dpa.file', 'measures')" variant="secondary" size="sm" target="_blank">
|
||||
<x-ui.icon name="shield" class="size-4" />{{ __('dpa.read_measures') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
<span class="font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span>
|
||||
</div>
|
||||
|
||||
@if ($dpaAcceptance)
|
||||
{{-- What was agreed, when, and by whom — the same three
|
||||
facts the register holds. R19: stored in UTC, read on
|
||||
the wall clock. --}}
|
||||
<p class="rounded border border-line bg-surface-2 p-4 text-sm leading-relaxed text-body">
|
||||
{{ __('dpa.accepted_on', [
|
||||
'version' => $dpa->version,
|
||||
'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'),
|
||||
]) }}
|
||||
</p>
|
||||
@else
|
||||
<div class="rounded border border-accent-border bg-accent-subtle p-4">
|
||||
<p class="text-sm leading-relaxed text-body">{{ __('dpa.accept_hint') }}</p>
|
||||
<x-ui.button variant="primary" size="sm" class="mt-3"
|
||||
wire:click="acceptProcessingAgreement" wire:loading.attr="disabled" wire:target="acceptProcessingAgreement">
|
||||
{{ __('dpa.accept_cta') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
|
|
|
|||
|
|
@ -59,6 +59,26 @@ Route::get('/mail/preview/{key}', function (string $key) {
|
|||
// into a console page: this is the document itself.
|
||||
return response($mailable->render());
|
||||
})->name('mail.preview.show');
|
||||
// The processing agreement (Art. 28 DSGVO): the document, the version in force,
|
||||
// and who has accepted it. The TEXT is uploaded, never written here.
|
||||
Route::get('/processing-agreement', Admin\ProcessingAgreements::class)->name('dpa');
|
||||
Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, string $which) {
|
||||
abort_unless(auth('operator')->user()?->can('dpa.manage') ?? false, 403);
|
||||
abort_unless(in_array($which, ['agreement', 'measures'], true), 404);
|
||||
|
||||
$version = App\Models\DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
|
||||
$path = $which === 'agreement' ? $version->agreement_path : $version->measures_path;
|
||||
|
||||
abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404);
|
||||
|
||||
// Inline: a contract is read before it is filed, and a download that opens
|
||||
// nowhere is a download somebody has to go looking for.
|
||||
return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline',
|
||||
]);
|
||||
})->name('dpa.file');
|
||||
|
||||
// The answers an operator gives over and over, written once — see
|
||||
// Admin\MailTemplates.
|
||||
Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates');
|
||||
|
|
|
|||
|
|
@ -304,6 +304,25 @@ $portal = function () {
|
|||
);
|
||||
})->name('invoices.pdf');
|
||||
|
||||
// The processing agreement the customer is being asked to accept — the
|
||||
// version in FORCE, never one named in the address: which document
|
||||
// applies is ours to say, not a parameter.
|
||||
Route::get('/processing-agreement/{which}', function (string $which) {
|
||||
abort_unless(in_array($which, ['agreement', 'measures'], true), 404);
|
||||
|
||||
$version = app(App\Services\Legal\ProcessingAgreement::class)->current();
|
||||
$path = $version === null
|
||||
? null
|
||||
: ($which === 'agreement' ? $version->agreement_path : $version->measures_path);
|
||||
|
||||
abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404);
|
||||
|
||||
return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline',
|
||||
]);
|
||||
})->name('dpa.file');
|
||||
|
||||
Route::get('/billing', Billing::class)->name('billing');
|
||||
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
||||
Route::get('/support', Support::class)->name('support');
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ use Spatie\Permission\Models\Role;
|
|||
it('moves every permission and role to the operator guard, leaving none behind', function () {
|
||||
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
|
||||
->and(Role::where('guard_name', 'web')->count())->toBe(0)
|
||||
// 17 from the original seed, plus customers.grant_plan and
|
||||
// instances.restart — the latter created straight onto this guard,
|
||||
// because since this migration `web` is where a permission goes to
|
||||
// match nobody at all.
|
||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(19)
|
||||
// 17 from the original seed, plus customers.grant_plan,
|
||||
// instances.restart and dpa.manage — the later ones created straight
|
||||
// onto this guard, because since this migration `web` is where a
|
||||
// permission goes to match nobody at all.
|
||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(20)
|
||||
->and(Role::where('guard_name', 'operator')->count())->toBe(6);
|
||||
});
|
||||
|
||||
|
|
@ -157,7 +157,9 @@ it('preflights every customer conflict before mutating anything, listing all of
|
|||
// Nothing mutated: still exactly the pre-migration shape, not "moved and
|
||||
// then rolled back" — there is nothing here to roll back on a real
|
||||
// server, which is the whole reason this has to be checked up front.
|
||||
expect(Permission::where('guard_name', 'web')->count())->toBe(19)
|
||||
// Every capability this installation has, pushed back to `web` above to
|
||||
// stage the pre-migration shape — the figure grows with each new one.
|
||||
expect(Permission::where('guard_name', 'web')->count())->toBe(20)
|
||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(0)
|
||||
->and(Role::where('guard_name', 'web')->count())->toBe(6)
|
||||
->and(Role::where('guard_name', 'operator')->count())->toBe(0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
<?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 is never this application's. It is uploaded.
|
||||
*/
|
||||
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']);
|
||||
|
||||
$page->assertSee(__('dpa.title'))
|
||||
->assertSee(__('dpa.state_open'))
|
||||
->assertSee(__('dpa.accept_cta'))
|
||||
->call('acceptProcessingAgreement')
|
||||
->assertDispatched('notify');
|
||||
|
||||
$acceptance = DpaAcceptance::query()->where('customer_id', $customer->id)->sole();
|
||||
|
||||
// What makes it evidence rather than a flag: the version, the moment, the
|
||||
// login that pressed it, and where from.
|
||||
expect($acceptance->dpa_version_id)->toBe($version->id)
|
||||
->and($acceptance->user_id)->toBe($user->id)
|
||||
->and($acceptance->accepted_at)->not->toBeNull()
|
||||
->and($acceptance->ip)->not->toBeNull();
|
||||
|
||||
// And the card now states it rather than asking again.
|
||||
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||
->assertSee(__('dpa.state_accepted'))
|
||||
->assertDontSee(__('dpa.accept_cta'));
|
||||
});
|
||||
|
||||
it('counts pressing twice as one agreement', function () {
|
||||
publishedDpa();
|
||||
[$user, $customer] = dpaCustomer();
|
||||
|
||||
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||
->call('acceptProcessingAgreement')
|
||||
->call('acceptProcessingAgreement');
|
||||
|
||||
expect(DpaAcceptance::query()->where('customer_id', $customer->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('asks again when a new version comes into force, and keeps the old acceptance', function () {
|
||||
// Acceptance is per version: a new one supersedes the last, and accepting a
|
||||
// superseded document is not accepting the one in force. The old row stays —
|
||||
// it was true when it was made, and the history is the point.
|
||||
$first = publishedDpa('1.0');
|
||||
[$user, $customer] = dpaCustomer();
|
||||
|
||||
app(ProcessingAgreement::class)->accept($customer);
|
||||
|
||||
expect(app(ProcessingAgreement::class)->accepted($customer))->toBeTrue();
|
||||
|
||||
$second = DpaVersion::query()->create([
|
||||
'version' => '2.0',
|
||||
'agreement_path' => $first->agreement_path,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
expect(app(ProcessingAgreement::class)->current()->id)->toBe($second->id)
|
||||
->and(app(ProcessingAgreement::class)->accepted($customer))->toBeFalse()
|
||||
// Not deleted, not overwritten.
|
||||
->and(DpaAcceptance::query()->where('dpa_version_id', $first->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('serves the document to the customer, and only the one in force', function () {
|
||||
publishedDpa();
|
||||
[$user] = dpaCustomer();
|
||||
|
||||
$this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertOk();
|
||||
$this->actingAs($user)->get(route('dpa.file', 'measures'))->assertOk();
|
||||
|
||||
// Which document applies is ours to say, not a parameter somebody can put
|
||||
// in the address.
|
||||
$this->actingAs($user)->get(route('dpa.file', 'sonstwas'))->assertNotFound();
|
||||
});
|
||||
|
||||
it('does not hand the agreement to a stranger', function () {
|
||||
publishedDpa();
|
||||
|
||||
$this->get(route('dpa.file', 'agreement'))->assertRedirect();
|
||||
});
|
||||
|
||||
it('answers 404 rather than 500 when nothing is published', function () {
|
||||
[$user] = dpaCustomer();
|
||||
|
||||
$this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertNotFound();
|
||||
});
|
||||
|
||||
// ---- The console ----
|
||||
|
||||
it('takes an upload as a draft, and only a second act puts it in force', function () {
|
||||
// Publishing leaves every customer who accepted the previous version
|
||||
// outstanding again. That is correct, and expensive to trigger by dropping a
|
||||
// file on a form.
|
||||
Storage::fake('local');
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(ProcessingAgreements::class)
|
||||
->set('version', '3.0')
|
||||
->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf'))
|
||||
->call('upload')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$version = DpaVersion::query()->where('version', '3.0')->sole();
|
||||
|
||||
expect($version->isPublished())->toBeFalse()
|
||||
->and(app(ProcessingAgreement::class)->current())->toBeNull()
|
||||
->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue();
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(ProcessingAgreements::class)
|
||||
->call('publish', $version->uuid);
|
||||
|
||||
expect(app(ProcessingAgreement::class)->current()?->id)->toBe($version->id);
|
||||
});
|
||||
|
||||
it('takes the agreement as a PDF and nothing else', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(ProcessingAgreements::class)
|
||||
->set('version', '4.0')
|
||||
->set('agreement', UploadedFile::fake()->create('avv.docx', 20, 'application/msword'))
|
||||
->call('upload')
|
||||
->assertHasErrors(['agreement']);
|
||||
});
|
||||
|
||||
it('refuses two versions under one name', function () {
|
||||
publishedDpa('1.0');
|
||||
Storage::fake('local');
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(ProcessingAgreements::class)
|
||||
->set('version', '1.0')
|
||||
->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf'))
|
||||
->call('upload')
|
||||
->assertHasErrors(['version']);
|
||||
});
|
||||
|
||||
it('keeps the whole thing to operators who may manage it', function () {
|
||||
// Its own capability: whoever keeps the platform running does not thereby
|
||||
// decide what every customer is asked to agree to.
|
||||
Livewire::actingAs(operator('Read-only'), 'operator')
|
||||
->test(ProcessingAgreements::class)
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs(operator('Read-only'), 'operator')
|
||||
->get(route('admin.dpa'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('confirms publication in a modal rather than on one click', function () {
|
||||
// R23, and for a reason beyond the rule: this is the click that makes every
|
||||
// customer outstanding again.
|
||||
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/processing-agreements.blade.php'));
|
||||
|
||||
expect($page)->toContain("component: 'admin.confirm-publish-dpa'");
|
||||
|
||||
$modal = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/ConfirmPublishDpa.php'));
|
||||
|
||||
expect($modal)->toContain("dispatch('dpa-publish-confirmed'")
|
||||
// The modal decides nothing: the permission check stays where it was.
|
||||
->and($modal)->not->toContain('published_at');
|
||||
});
|
||||
Loading…
Reference in New Issue