Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details

Three things reported together.

── Editing belongs in a modal (R20)

The seats table grew its input fields into the row. It worked and it looked
broken: the row grew, the columns beside it jumped, and a table half in edit
mode reads as a rendering fault rather than as a form. The project already had
the answer — EditDatacenter, whose own header comment says it avoids exactly
that row-height jump — and the seats table simply did not use it.

EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route
middleware, so it resolves the customer itself and re-reads the record rather
than trusting a hydrated property: a forged addressEditable would otherwise
open the address of an accepted seat, and the address is the person — editing
it hands one employee's access to another with nobody told. Only an invitation
still in flight can have its address corrected, which is the case that actually
comes up.

The actions column is now always drawn. It used to disappear when the only seat
was the owner, on the reasoning that there was nothing to act on. But every
seat can be renamed, and a column that vanishes does not read as "not
applicable here" — it reads as "this product cannot do that", which is how it
was reported, three times. The owner's row says "Geschützt" rather than leaving
an empty cell.

Also caught here: the edit fields carried class-wide #[Validate] attributes, so
an empty edit form made the INVITE button fail on a field the invite form does
not have. Rules for an action belong to the action.

── The update button

"I cannot run an update, it says everything is current." `behind` is a READING
taken by the agent every five minutes, not the state of the world — push a
commit and the console insists it is up to date and refuses to act. The agent
does its own fetch before deciding, so asking against a stale reading costs one
fetch and finds nothing; being locked out costs the deployment. The button is
now offered whenever an agent is alive and no run is in flight, labelled for
what it does rather than for what the last reading said.

── The update never announced that it had finished

Because the thing being watched restarts the thing doing the watching. Mid-run
every wire:poll request fails, and what answers afterwards is a new build being
questioned by the old page's JavaScript — so the card sat on "läuft" until
somebody reloaded by hand. A small Alpine watcher now asks a plain JSON
endpoint (no Livewire, no component state, no assets), treats a failed request
as the restart rather than as a fault worth giving up over, and reloads once
the build it is looking at is no longer the build it started with.

── Support

The page was a decorated placeholder: a button that raised a toast saying the
form was "only hinted at in the prototype", three invented ticket titles living
in the translation file, and no way to see what became of anything. It looked
thin because nothing on it was real.

It now leads with the customer's own requests — what somebody arriving here
wants to know is what they asked and whether anyone answered — with contact
details moved to the side where they belong. The form attaches the plan, the
instance and who is asking automatically: making a customer describe their own
server back to the people who built it is the part of support people hate. FAQ
answers end where the thing can actually be done, and "is it me or is it you"
is answered by a link to the status page.

637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page
view may grow an input field inside a <td>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/mailboxes tested-20260727-1603-9b8d5df
nexxo 2026-07-27 17:55:49 +02:00
parent bd779f00e5
commit 9b8d5dfd1e
25 changed files with 1271 additions and 93 deletions

View File

@ -106,3 +106,47 @@ darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten,
Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()` Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()`
verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe
Zeitpunkt zurück. Zeitpunkt zurück.
---
## R20 — Bearbeiten passiert im Modal, nie in der Zeile
**Sobald etwas Eingabefelder hat, geht ein Modal auf.**
Verboten:
1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein `<input>`, kein
`<textarea>` in einem `<td>`. Die Zeile wächst, die Spalten daneben springen,
und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein
Darstellungsfehler, nicht wie ein Formular.
2. **Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft.** Er
schickt `openModal` — alles andere ist der Inline-Editor unter neuem Namen.
Nicht betroffen: Formulare, die *die Seite sind* — Anlegen-Formulare, die
Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen
bestehenden Datensatz an Ort und Stelle.
Ausnahmen, die kein Modal brauchen: ein einzelnes `<select>` oder eine
Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert,
keine Höhenänderung.
### Warum das aufgeschrieben wurde
Das Projekt hatte das Modal längst — `EditDatacenter`, mit genau dieser
Begründung im Kopfkommentar („avoids the row-height jump of inline editing").
Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die
Bearbeitung inline gebaut, obwohl das Muster danebenlag.
### Wie es jetzt gebaut ist
- `App\Livewire\EditSeat` als `ModalComponent`, geöffnet über
`$dispatch('openModal', { component: 'edit-seat', arguments: { uuid } })`.
- Ein Modal ist **ohne** die Route-Middleware der Seite erreichbar. Es löst
deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom
Browser hydrierten Eigenschaft zu glauben.
### Erzwungen durch
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.

108
app/Livewire/EditSeat.php Normal file
View File

@ -0,0 +1,108 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Seat;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one seat, in a modal.
*
* Editing used to happen in the row itself. It worked and it looked wrong: the
* row grew, the columns beside it jumped, and a table half in edit mode reads
* as a rendering fault rather than as a form. Anything with fields of its own
* gets a modal see R20.
*
* A modal is reachable without passing the page's route middleware, so the
* customer is resolved here rather than trusted from the caller: the uuid comes
* from the browser and must never reach across customers.
*/
class EditSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid = '';
public string $name = '';
public string $email = '';
/** Owners cannot be suspended or removed; everyone can be renamed. */
public bool $isOwner = false;
/**
* Whether the address may still be corrected.
*
* Only while the invitation is in flight. Once someone has accepted, the
* address IS the person: editing it would hand one employee's access to
* another with nobody told a transfer of access wearing the clothes of a
* rename.
*/
public bool $addressEditable = false;
public function mount(string $uuid): void
{
$seat = $this->seat($uuid);
abort_if($seat === null, 404);
$this->uuid = $uuid;
$this->name = (string) $seat->name;
$this->email = (string) $seat->email;
$this->isOwner = $seat->role === 'owner';
$this->addressEditable = $seat->status === 'invited';
}
public function save()
{
$customer = $this->requireCustomer();
$seat = $this->seat($this->uuid);
if ($customer === null || $seat === null) {
return $this->closeModal();
}
$rules = ['name' => 'nullable|string|max:255'];
// Re-read from the record, never from the hydrated property: a forged
// addressEditable would otherwise open the address of an accepted seat.
if ($seat->status === 'invited') {
$rules['email'] = 'required|email|max:255';
}
$data = $this->validate($rules);
$changes = ['name' => trim($data['name'] ?? '') ?: null];
if ($seat->status === 'invited') {
$address = trim($data['email']);
if ($address !== $seat->email) {
if ($customer->seats()->where('email', $address)->whereKeyNot($seat->id)->exists()) {
$this->addError('email', __('users.duplicate'));
return null;
}
$changes['email'] = $address;
}
}
$seat->update($changes);
$this->dispatch('notify', message: __('users.saved'));
return $this->redirectRoute('users', navigate: true);
}
private function seat(string $uuid): ?Seat
{
return $this->customer()?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
return view('livewire.edit-seat');
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SupportRequest;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Raise a support request.
*
* The customer is asked for three things and nothing else: what it is about,
* a subject, and the question. Everything an operator would otherwise have to
* ask for which customer, which instance, which plan the system already
* knows and attaches itself. Making somebody describe their own server back to
* the people who built it is the part of support that annoys people most.
*/
class NewSupportRequest extends ModalComponent
{
use ResolvesCustomer;
public string $category = 'technical';
public string $subject = '';
public string $body = '';
public function save()
{
$customer = $this->requireCustomer();
if ($customer === null) {
return $this->closeModal();
}
$data = $this->validate([
'category' => ['required', Rule::in(SupportRequest::CATEGORIES)],
'subject' => 'required|string|min:3|max:150',
'body' => 'required|string|min:10|max:5000',
]);
$instance = $customer->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')
->first()
?? $customer->instances()->latest('id')->first();
SupportRequest::create([
'customer_id' => $customer->id,
'instance_id' => $instance?->id,
'subject' => $data['subject'],
'category' => $data['category'],
'body' => $data['body'],
'status' => 'open',
// The person, not the account: on a shared login the account name
// says nothing about who is actually asking.
'reported_by' => auth()->user()?->name ?: auth()->user()?->email,
]);
$this->dispatch('notify', message: __('support.sent'));
return $this->redirectRoute('support', navigate: true);
}
public function render()
{
return view('livewire.new-support-request', [
'categories' => SupportRequest::CATEGORIES,
]);
}
}

View File

@ -2,19 +2,39 @@
namespace App\Livewire; namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SupportRequest;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
#[Layout('layouts.portal-app')] #[Layout('layouts.portal-app')]
class Support extends Component class Support extends Component
{ {
use ResolvesCustomer;
public function render() public function render()
{ {
$customer = $this->customer();
$requests = $customer
? SupportRequest::query()
->where('customer_id', $customer->id)
->latest('created_at')
->limit(20)
->get()
: collect();
return view('livewire.support', [ return view('livewire.support', [
'requests' => $requests,
'openCount' => $requests->filter(fn (SupportRequest $r) => $r->isOpen())->count(),
// The answers link into the panel where the panel can actually do
// the thing being asked about — an FAQ that only describes a button
// makes the reader hunt for it.
'faqs' => [ 'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')], ['q' => __('support.faq_q1'), 'a' => __('support.faq_a1'), 'to' => route('backups'), 'cta' => __('support.faq_to_backups')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')], ['q' => __('support.faq_q2'), 'a' => __('support.faq_a2'), 'to' => route('users'), 'cta' => __('support.faq_to_users')],
['q' => __('support.faq_q3'), 'a' => __('support.faq_a3')], ['q' => __('support.faq_q3'), 'a' => __('support.faq_a3'), 'to' => null, 'cta' => null],
['q' => __('support.faq_q4'), 'a' => __('support.faq_a4'), 'to' => route('billing'), 'cta' => __('support.faq_to_billing')],
], ],
]); ]);
} }

View File

@ -25,6 +25,7 @@ class Users extends Component
#[Validate('required|in:admin,member,readonly')] #[Validate('required|in:admin,member,readonly')]
public string $inviteRole = 'member'; public string $inviteRole = 'member';
public function mount(): void public function mount(): void
{ {
// Every customer starts with themselves as the owner seat. firstOrCreate // Every customer starts with themselves as the owner seat. firstOrCreate
@ -189,6 +190,7 @@ class Users extends Component
} }
} }
private function ownerCount(): int private function ownerCount(): int
{ {
$customer = $this->customer(); $customer = $this->customer();
@ -225,13 +227,12 @@ class Users extends Component
$customer = $this->customer(); $customer = $this->customer();
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect(); $seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
// The owner can be neither re-invited nor revoked, so a table holding // The actions column is ALWAYS drawn. It used to be hidden when the
// only the owner has nothing to act on and should not carry a column // only seat was the owner, on the reasoning that there was nothing to
// of empty cells. // act on — but every seat can be renamed, and a column that disappears
$hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner'); // does not read as "nothing applies here", it reads as "this product
// cannot do that". Which is exactly how it was reported.
return view('livewire.users', [ return view('livewire.users', [
'hasActions' => $hasActions,
'seats' => $seats, 'seats' => $seats,
'used' => $customer ? $this->usedSeats($customer) : 0, 'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0, 'limit' => $customer ? $this->seatLimit($customer) : 0,

View File

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One question from a customer, and what became of it.
*/
class SupportRequest extends Model
{
use HasFactory;
use HasUuids;
/** What a customer can pick. Kept short: a long list is a form nobody finishes. */
public const CATEGORIES = ['technical', 'billing', 'account', 'other'];
/** Anything not in here is still open as far as the customer is concerned. */
public const CLOSED = ['answered', 'closed'];
protected $fillable = [
'customer_id', 'instance_id', 'subject', 'category', 'body', 'status', 'reported_by', 'answered_at',
];
protected function casts(): array
{
return ['answered_at' => 'datetime'];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
public function isOpen(): bool
{
return ! in_array($this->status, self::CLOSED, true);
}
}

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A customer's support requests.
*
* The page had a button that raised a toast saying the form was "only hinted
* at in the prototype", three invented ticket titles in the translation file,
* and no way to see what you had asked. It looked thin because there was
* nothing on it.
*
* The record exists so that both sides can see the same thing: the customer
* gets a history instead of a sent e-mail they cannot check on, and the
* console gets the request with the instance already attached, rather than
* asking the customer which server they mean.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('support_requests', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
// Whose cloud this is about. Nullable: a question can arrive before
// provisioning finishes, and losing the instance later must not
// take the conversation with it.
$table->foreignId('instance_id')->nullable()->constrained()->nullOnDelete();
$table->string('subject');
$table->string('category', 32)->default('other');
$table->text('body');
// open → in_progress → answered/closed. Kept as a string rather
// than an enum so a workflow change is not a migration.
$table->string('status', 24)->default('open');
// Who wrote it — the seat, not the account. On a shared login the
// account name says nothing about who is actually asking.
$table->string('reported_by')->nullable();
$table->timestamp('answered_at')->nullable();
$table->timestamps();
// The customer's own list, newest first — the only query the panel
// makes.
$table->index(['customer_id', 'created_at']);
// The console's queue: everything still waiting, oldest first.
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('support_requests');
}
};

View File

@ -82,6 +82,7 @@ return [
'update_source' => 'Quelle', 'update_source' => 'Quelle',
'update_deployed' => 'Ausgerollt', 'update_deployed' => 'Ausgerollt',
'update_checked' => 'Zuletzt geprüft', 'update_checked' => 'Zuletzt geprüft',
'update_recheck' => 'Jetzt prüfen und aktualisieren',
'update_now' => 'Jetzt aktualisieren', 'update_now' => 'Jetzt aktualisieren',
'update_running' => 'Läuft gerade', 'update_running' => 'Läuft gerade',
'update_available' => ':n Aktualisierung(en) verfügbar', 'update_available' => ':n Aktualisierung(en) verfügbar',

View File

@ -1,27 +1,57 @@
<?php <?php
return [ return [
'eyebrow' => 'Hilfe & Kontakt',
'title' => 'Support', 'title' => 'Support',
'subtitle' => 'Wir sind für Sie da — persönlich und schnell.',
'new' => 'Anfrage stellen', 'new' => 'Anfrage stellen',
'new_toast' => 'Support-Formular im Prototyp nur angedeutet.', 'new_hint' => 'Beschreiben Sie kurz, worum es geht. Wir melden uns so rasch wie möglich.',
'send' => 'Absenden',
'sent' => 'Ihre Anfrage ist eingegangen.',
'attached_hint' => 'Ihr Paket, Ihre Instanz und Ihre Kontaktdaten hängen wir automatisch an — Sie müssen nichts davon heraussuchen.',
'category' => 'Worum geht es?',
'category_technical' => 'Technisches Problem',
'category_billing' => 'Rechnung & Zahlung',
'category_account' => 'Zugang & Benutzer',
'category_other' => 'Sonstiges',
'subject' => 'Betreff',
'body' => 'Ihre Nachricht',
'tickets' => 'Ihre Anfragen',
'open_count' => '{1} :count offen|[2,*] :count offen',
'answered_at' => 'Beantwortet am :when',
'status_open' => 'Offen',
'status_in_progress' => 'In Bearbeitung',
'status_answered' => 'Beantwortet',
'status_closed' => 'Erledigt',
'empty_title' => 'Noch keine Anfragen',
'empty_body' => 'Wenn etwas unklar ist oder nicht funktioniert, schreiben Sie uns. Wir sehen dabei sofort, um welche Cloud es geht.',
'contact' => 'Kontakt',
'contact_email' => 'E-Mail',
'contact_hours' => 'Erreichbarkeit', 'contact_hours' => 'Erreichbarkeit',
'contact_hours_val' => 'MoFr, 0818 Uhr', 'contact_hours_val' => 'MoFr, 0818 Uhr',
'contact_email' => 'E-Mail',
'contact_sla' => 'Reaktionszeit', 'contact_sla' => 'Reaktionszeit',
'contact_sla_val' => 'Antwort am selben Werktag', 'contact_sla_val' => 'Antwort am selben Werktag',
'tickets' => 'Ihre Anfragen',
'status_open' => 'Offen', 'status_title' => 'Systemstatus',
'status_progress' => 'In Bearbeitung', 'status_body' => 'Falls etwas nicht erreichbar ist: hier steht, ob es an uns liegt.',
'status_closed' => 'Erledigt', 'status_cta' => 'Status ansehen',
'ticket_migration' => 'Datenübernahme aus Dropbox',
'ticket_training' => 'Einschulungstermin Sekretariat',
'ticket_smtp' => 'E-Mail-Versand einrichten',
'faq' => 'Häufige Fragen', 'faq' => 'Häufige Fragen',
'faq_q1' => 'Wie stelle ich ein Backup wieder her?', 'faq_q1' => 'Wie stelle ich ein Backup wieder her?',
'faq_a1' => 'Wählen Sie unter „Backups" den gewünschten Zeitpunkt und klicken Sie auf „Wiederherstellen". Wir bestätigen die Wiederherstellung binnen einer Stunde.', 'faq_a1' => 'Wählen Sie unter „Backups" den gewünschten Zeitpunkt und fordern Sie die Wiederherstellung an. Wir bestätigen sie, bevor etwas überschrieben wird.',
'faq_to_backups' => 'Zu den Backups',
'faq_q2' => 'Kann ich weitere Benutzer hinzufügen?', 'faq_q2' => 'Kann ich weitere Benutzer hinzufügen?',
'faq_a2' => 'Ja, bis zum Limit Ihres Pakets jederzeit unter „Benutzer". Zusätzliche Plätze buchen Sie mit einem Klick dazu.', 'faq_a2' => 'Ja, jederzeit bis zum Limit Ihres Pakets. Sind alle Plätze belegt, erweitern Sie das Paket unter „Paket & Addons".',
'faq_to_users' => 'Zu den Benutzern',
'faq_q3' => 'Wo liegen meine Daten?', 'faq_q3' => 'Wo liegen meine Daten?',
'faq_a3' => 'Ausschließlich in der EU, im Rechenzentrum Falkenstein — verschlüsselt und DSGVO-konform.', 'faq_a3' => 'Ausschließlich in der EU, verschlüsselt und DSGVO-konform. Das Land Ihres Standorts steht in Ihrem Stammblatt.',
'faq_q4' => 'Wie ändere ich mein Paket?',
'faq_a4' => 'Unter „Paket & Addons". Ein größeres Paket gilt sofort, ein kleineres zum Ende der laufenden Periode — und nur, wenn Ihre Daten und Benutzer hineinpassen.',
'faq_to_billing' => 'Zu Paket & Addons',
]; ];

View File

@ -41,6 +41,17 @@ return [
'status_invited' => 'Eingeladen', 'status_invited' => 'Eingeladen',
'status_revoked' => 'Entfernt', 'status_revoked' => 'Entfernt',
'edit_title' => 'Benutzer bearbeiten',
'email_invited_hint' => 'Die Einladung wurde noch nicht angenommen — die Adresse kann korrigiert werden.',
'email_locked_hint' => 'Die Adresse ist die Kennung dieser Person und kann nicht geändert werden. Entfernen Sie den Zugang und laden Sie neu ein.',
'edit' => 'Bearbeiten',
'save' => 'Speichern',
'saved' => 'Änderungen gespeichert.',
'name_optional' => 'Name (optional)',
// Steht statt eines leeren Feldes: der Inhaber behält seinen eigenen Zugang.
'owner_protected' => 'Geschützt',
'revoke_confirm' => ':name entfernen? Der Zugang endet sofort.',
'resend' => 'Erneut senden', 'resend' => 'Erneut senden',
'revoke' => 'Entfernen', 'revoke' => 'Entfernen',

View File

@ -82,6 +82,7 @@ return [
'update_source' => 'Source', 'update_source' => 'Source',
'update_deployed' => 'Deployed', 'update_deployed' => 'Deployed',
'update_checked' => 'Last checked', 'update_checked' => 'Last checked',
'update_recheck' => 'Check now and update',
'update_now' => 'Update now', 'update_now' => 'Update now',
'update_running' => 'Running', 'update_running' => 'Running',
'update_available' => ':n update(s) available', 'update_available' => ':n update(s) available',

View File

@ -1,27 +1,57 @@
<?php <?php
return [ return [
'eyebrow' => 'Help & contact',
'title' => 'Support', 'title' => 'Support',
'subtitle' => 'We are here for you — personal and fast.',
'new' => 'New request', 'new' => 'Raise a request',
'new_toast' => 'Support form is only indicated in this prototype.', 'new_hint' => 'Tell us briefly what this is about. We will come back to you as quickly as we can.',
'contact_hours' => 'Availability', 'send' => 'Send',
'contact_hours_val' => 'MonFri, 8am6pm', 'sent' => 'Your request has been received.',
'contact_email' => 'Email', 'attached_hint' => 'Your plan, your instance and your contact details are attached automatically — you do not have to look any of it up.',
'contact_sla' => 'Response time',
'contact_sla_val' => 'Answer on the same working day', 'category' => 'What is this about?',
'category_technical' => 'Technical problem',
'category_billing' => 'Invoice & payment',
'category_account' => 'Access & users',
'category_other' => 'Something else',
'subject' => 'Subject',
'body' => 'Your message',
'tickets' => 'Your requests', 'tickets' => 'Your requests',
'open_count' => '{1} :count open|[2,*] :count open',
'answered_at' => 'Answered on :when',
'status_open' => 'Open', 'status_open' => 'Open',
'status_progress' => 'In progress', 'status_in_progress' => 'In progress',
'status_closed' => 'Resolved', 'status_answered' => 'Answered',
'ticket_migration' => 'Data import from Dropbox', 'status_closed' => 'Closed',
'ticket_training' => 'Training session for the office',
'ticket_smtp' => 'Set up email delivery', 'empty_title' => 'No requests yet',
'faq' => 'Frequently asked questions', 'empty_body' => 'If something is unclear or not working, write to us. We can see straight away which cloud you mean.',
'contact' => 'Contact',
'contact_email' => 'E-mail',
'contact_hours' => 'Availability',
'contact_hours_val' => 'MonFri, 08:0018:00',
'contact_sla' => 'Response time',
'contact_sla_val' => 'A reply the same working day',
'status_title' => 'System status',
'status_body' => 'If something is unreachable, this page says whether it is us.',
'status_cta' => 'View status',
'faq' => 'Frequent questions',
'faq_q1' => 'How do I restore a backup?', 'faq_q1' => 'How do I restore a backup?',
'faq_a1' => 'Under “Backups”, pick the point in time and click “Restore”. We confirm the restore within one hour.', 'faq_a1' => 'Under "Backups", pick the point in time and request the restore. We confirm it with you before anything is overwritten.',
'faq_to_backups' => 'Go to backups',
'faq_q2' => 'Can I add more users?', 'faq_q2' => 'Can I add more users?',
'faq_a2' => 'Yes, any time under “Users” up to your plan limit. Add extra seats with one click.', 'faq_a2' => 'Yes, at any time up to your plan\'s limit. If every seat is taken, extend the plan under "Plan & add-ons".',
'faq_q3' => 'Where is my data stored?', 'faq_to_users' => 'Go to users',
'faq_a3' => 'Exclusively in the EU, at the Falkenstein data center — encrypted and GDPR-compliant.', 'faq_q3' => 'Where is my data held?',
'faq_a3' => 'In the EU only, encrypted and GDPR-compliant. The country of your location is on your record sheet.',
'faq_q4' => 'How do I change my plan?',
'faq_a4' => 'Under "Plan & add-ons". A larger plan applies immediately, a smaller one at the end of the current period — and only if your data and users fit inside it.',
'faq_to_billing' => 'Go to plan & add-ons',
]; ];

View File

@ -41,6 +41,17 @@ return [
'status_invited' => 'Invited', 'status_invited' => 'Invited',
'status_revoked' => 'Removed', 'status_revoked' => 'Removed',
'edit_title' => 'Edit user',
'email_invited_hint' => 'The invitation has not been accepted yet — the address can still be corrected.',
'email_locked_hint' => 'The address identifies this person and cannot be changed. Remove the seat and invite again instead.',
'edit' => 'Edit',
'save' => 'Save',
'saved' => 'Changes saved.',
'name_optional' => 'Name (optional)',
// Shown instead of an empty cell: the owner keeps their own access.
'owner_protected' => 'Protected',
'revoke_confirm' => 'Remove :name? Their access ends immediately.',
'resend' => 'Resend', 'resend' => 'Resend',
'revoke' => 'Remove', 'revoke' => 'Remove',

View File

@ -94,6 +94,82 @@ window.fetch = async (...args) => {
}; };
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
// ── Watching a deployment that restarts the watcher ──────────────────
//
// wire:poll cannot see an update through to the end, because the update
// restarts the containers serving the poll. Mid-run every request fails;
// afterwards a NEW build is being questioned by the OLD page's JavaScript.
// The card therefore sat on "running" until somebody reloaded the page by
// hand, which is how it was reported.
//
// This asks a plain JSON endpoint instead — no Livewire, no component
// state, no assets — and, crucially, treats a failed request as the
// restart rather than as an error worth giving up over.
window.Alpine.data('updateWatcher', ({ url, commit, running }) => ({
// The build this page was rendered from. When the server stops
// agreeing, the page is stale by definition and must be replaced —
// this is the only signal that survives the restart, since the run has
// already ended by the time we can ask again.
renderedCommit: commit,
wasRunning: running,
timer: null,
init() {
this.schedule();
// Nothing to watch while the tab is hidden; pick straight back up
// rather than waiting out a whole interval on return.
document.addEventListener('visibilitychange', () => {
if (!document.hidden) this.check();
});
},
schedule() {
clearTimeout(this.timer);
this.timer = setTimeout(() => this.check(), this.wasRunning ? 3000 : 20000);
},
async check() {
try {
const response = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
// A redirect to the login page answers 200 with HTML. Reload
// rather than parse it: the session is what needs attention.
if (!response.ok || !response.headers.get('content-type')?.includes('json')) {
throw new Error('not ready');
}
const state = await response.json();
if (state.commit && this.renderedCommit && state.commit !== this.renderedCommit) {
window.location.reload();
return;
}
// The run ended without the build changing — a check that found
// nothing, or a failure. Either way the card has something new
// to say.
if (this.wasRunning && !state.running) {
window.location.reload();
return;
}
this.wasRunning = state.running;
} catch {
// Expected while the containers are down. Keep asking: this is
// the middle of the very event being watched, not a fault.
this.wasRunning = true;
}
this.schedule();
},
destroy() {
clearTimeout(this.timer);
},
}));
// ── VPN config: copy / download ────────────────────────────────────── // ── VPN config: copy / download ──────────────────────────────────────
// navigator.clipboard only exists in a secure context, so over plain http // navigator.clipboard only exists in a secure context, so over plain http
// (the panel reached by IP) the copy button silently did nothing. Falls // (the panel reached by IP) the copy button silently did nothing. Falls

View File

@ -35,6 +35,7 @@
'arrow-left' => '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>', 'arrow-left' => '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>', 'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'rotate-ccw' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>', 'rotate-ccw' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>', 'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', 'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
]; ];

View File

@ -8,9 +8,23 @@
@if ($canManageSite) @if ($canManageSite)
{{-- Polls itself: an operator watching an update run should not have to {{-- Polls itself: an operator watching an update run should not have to
reload the page to find out whether it finished. Fast while reload the page to find out whether it finished. Fast while
something is happening, slow when nothing is. --}} something is happening, slow when nothing is.
wire:poll alone is not enough, because the thing being watched
restarts the thing doing the watching. Mid-run the requests fail,
and what answers afterwards is a NEW build being questioned by the
old page's JavaScript so the card sat on "läuft" until somebody
reloaded. The watcher below rides through that: it asks a plain
JSON endpoint, treats a failed request as the restart rather than
as an error, and reloads the page once the build it is looking at
is no longer the build it started with. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise" <div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ $update['running'] ? '3s' : '30s' }}> wire:poll.{{ $update['running'] ? '3s' : '30s' }}
x-data="updateWatcher({
url: '{{ route('admin.update.state') }}',
commit: @js($update['commit']),
running: @js($update['running']),
})">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@ -54,22 +68,39 @@
</dl> </dl>
</div> </div>
{{-- Offered only when there is something to install. Bound, not {{-- Bound, not @disabled(): the directive compiles to inline PHP
@disabled(): the directive compiles to inline PHP inside the inside the component tag, and Blade then stops seeing a
component tag, and Blade then stops seeing a component. --}} component.
The button used to be offered ONLY when the last check had
found something. But "behind" is a READING, taken every few
minutes it is not the state of the world. Push a commit
and for the next few minutes the console insists everything
is current and refuses to do anything about it, which is
precisely how it was reported. The agent fetches before it
decides, so asking against a stale reading costs one fetch
and finds nothing; being locked out costs the deployment.
Still disabled for the two cases where the request really
would go nowhere: a run already in flight, and no agent. --}}
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
@if ($update['running']) @if ($update['running'])
<x-ui.button variant="secondary" :disabled="true"> <x-ui.button variant="secondary" :disabled="true">
<x-ui.icon name="refresh" class="size-4 animate-spin" /> <x-ui.icon name="refresh" class="size-4 animate-spin" />
{{ __('admin_settings.update_running') }} {{ __('admin_settings.update_running') }}
</x-ui.button> </x-ui.button>
@elseif (! $update['agent_seen'])
<x-ui.button variant="secondary" :disabled="true">{{ __('admin_settings.update_unknown') }}</x-ui.button>
@elseif ($update['available']) @elseif ($update['available'])
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate"> <x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
{{ __('admin_settings.update_now') }} {{ __('admin_settings.update_now') }}
</x-ui.button> </x-ui.button>
@else @else
<x-ui.button variant="secondary" :disabled="true"> {{-- Named for what it does rather than for what the last
{{ $update['behind'] === 0 ? __('admin_settings.update_current') : __('admin_settings.update_unknown') }} reading said: it re-checks, and updates if there is
anything to update. --}}
<x-ui.button variant="secondary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
{{ __('admin_settings.update_recheck') }}
</x-ui.button> </x-ui.button>
@endif @endif
</div> </div>

View File

@ -0,0 +1,38 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-ink">{{ __('users.edit_title') }}</h3>
@if ($isOwner)
<span class="rounded-pill bg-accent-subtle px-2.5 py-0.5 text-xs font-semibold text-accent-text">{{ __('users.role_owner') }}</span>
@endif
</div>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="seat-name">{{ __('users.name_optional') }}</label>
<input id="seat-name" type="text" wire:model="name" wire:keydown.enter="save"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('name')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="seat-email">{{ __('users.invite_email') }}</label>
@if ($addressEditable)
<input id="seat-email" type="email" wire:model="email" wire:keydown.enter="save"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('users.email_invited_hint') }}</p>
@error('email')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
@else
{{-- Shown but not editable, and the reason said out loud: a
greyed-out field with no explanation is the thing people
ring about. --}}
<p class="mt-1.5 rounded-md border border-line bg-surface-2 px-3 py-2 font-mono text-sm text-muted">{{ $email }}</p>
<p class="mt-1 text-xs text-muted">{{ __('users.email_locked_hint') }}</p>
@endif
</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="save" wire:loading.attr="disabled" wire:target="save">{{ __('users.save') }}</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,39 @@
<div class="rounded-lg bg-surface p-6">
<h3 class="text-base font-semibold text-ink">{{ __('support.new') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('support.new_hint') }}</p>
<div class="mt-5 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="sr-category">{{ __('support.category') }}</label>
<select id="sr-category" wire:model="category" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($categories as $c)
<option value="{{ $c }}">{{ __('support.category_'.$c) }}</option>
@endforeach
</select>
@error('category')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="sr-subject">{{ __('support.subject') }}</label>
<input id="sr-subject" type="text" wire:model="subject" maxlength="150"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('subject')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="sr-body">{{ __('support.body') }}</label>
<textarea id="sr-body" wire:model="body" rows="6"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
@error('body')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
{{-- Said once, plainly. The alternative is a form that asks the customer
to describe their own server back to the people who built it. --}}
<p class="rounded-md border border-line bg-surface-2 px-3 py-2 text-xs text-muted">{{ __('support.attached_hint') }}</p>
</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="save" wire:loading.attr="disabled" wire:target="save">{{ __('support.send') }}</x-ui.button>
</div>
</div>

View File

@ -1,45 +1,138 @@
<div class="space-y-6" x-data="{ open: null, msgs: @js(['sent' => __('support.new_toast')]) }"> @php
<div class="flex flex-wrap items-center gap-3 animate-rise"> $locale = app()->getLocale();
<div> @endphp
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('support.title') }}</h1> <div class="space-y-3.5" x-data="{ open: null }">
<p class="mt-1 text-sm text-muted">{{ __('support.subtitle') }}</p> <header class="mb-2 flex flex-wrap items-center gap-3.5 animate-rise">
<div class="min-w-0 flex-1">
<p class="lbl">{{ __('support.eyebrow') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ __('support.title') }}
</h1>
</div> </div>
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.sent })">
<x-ui.button variant="ink" class="shrink-0"
x-on:click="$dispatch('openModal', { component: 'new-support-request' })">
<x-ui.icon name="plus" class="size-4" />{{ __('support.new') }} <x-ui.icon name="plus" class="size-4" />{{ __('support.new') }}
</x-ui.button> </x-ui.button>
</div> </header>
{{-- Contact --}} <div class="grid gap-3.5 min-[1101px]:grid-cols-[1fr_340px]">
@php $delays = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]']; @endphp {{-- ── The customer's own requests ──────────────────────────────────
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> The page used to lead with three cards about opening hours. What
@foreach ([ somebody arriving here actually wants to know is what they already
['life-buoy', __('support.contact_hours'), __('support.contact_hours_val')], asked and whether anybody has answered so that goes first, and
['external-link', __('support.contact_email'), 'support@clupilot.com'], the contact details move to the side where they belong. --}}
['shield-check', __('support.contact_sla'), __('support.contact_sla_val')], <div class="space-y-3.5">
] as $i => [$icon, $label, $val]) <section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
<div class="flex items-center gap-3 rounded-lg border border-line bg-surface p-4 shadow-xs animate-rise {{ $delays[$i] }}"> <div class="flex items-center justify-between gap-3 border-b border-line px-5 py-3.5">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$icon" class="size-5" /></span> <h2 class="font-semibold text-ink">{{ __('support.tickets') }}</h2>
<div class="min-w-0"> @if ($openCount > 0)
<p class="text-xs font-semibold text-muted">{{ $label }}</p> <span class="inline-flex shrink-0 items-center gap-[7px] rounded-pill border border-accent-border bg-accent-subtle px-[11px] py-1 text-xs font-medium text-accent-text">
<p class="mt-0.5 truncate text-sm font-medium text-ink">{{ $val }}</p> {{ trans_choice('support.open_count', $openCount, ['count' => $openCount]) }}
</span>
@endif
</div> </div>
</div>
@endforeach
</div>
{{-- Tickets --}} @forelse ($requests as $r)
<div class="rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]"> <article class="flex flex-wrap items-start gap-3 border-b border-line px-5 py-4 last:border-0">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2></div> <div class="min-w-0 flex-1">
<ul class="divide-y divide-line"> <p class="font-medium text-ink">{{ $r->subject }}</p>
@foreach ($faqs as $i => $f) <p class="mt-1 text-xs text-muted">
<li> {{ __('support.category_'.$r->category) }}
<button type="button" class="flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-medium text-ink hover:bg-surface-hover" @click="open = open === {{ $i }} ? null : {{ $i }}" :aria-expanded="open === {{ $i }}"> · {{ $r->created_at->local()->locale($locale)->isoFormat('LL, LT') }}
<span class="flex-1">{{ $f['q'] }}</span> @if ($r->reported_by) · {{ $r->reported_by }} @endif
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted transition-transform" :class="open === {{ $i }} ? 'rotate-180' : ''" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg> </p>
</button> @if ($r->answered_at)
<div x-show="open === {{ $i }}" x-cloak x-transition class="px-5 pb-4 text-sm text-muted">{{ $f['a'] }}</div> <p class="mt-1 text-xs text-success">
</li> {{ __('support.answered_at', ['when' => $r->answered_at->local()->locale($locale)->isoFormat('LL, LT')]) }}
@endforeach </p>
</ul> @endif
</div>
@php $tone = ['open' => 'info', 'in_progress' => 'provisioning', 'answered' => 'active', 'closed' => 'suspended'][$r->status] ?? 'info'; @endphp
<x-ui.badge :status="$tone" class="shrink-0">{{ __('support.status_'.$r->status) }}</x-ui.badge>
</article>
@empty
{{-- An empty state that says what to do, not "no data". --}}
<div class="px-5 py-10 text-center">
<span class="mx-auto grid size-11 place-items-center rounded-lg bg-surface-2 text-muted">
<x-ui.icon name="life-buoy" class="size-5" />
</span>
<p class="mt-3 font-medium text-ink">{{ __('support.empty_title') }}</p>
<p class="mx-auto mt-1 max-w-sm text-sm text-muted">{{ __('support.empty_body') }}</p>
<x-ui.button variant="secondary" class="mt-4"
x-on:click="$dispatch('openModal', { component: 'new-support-request' })">
{{ __('support.new') }}
</x-ui.button>
</div>
@endforelse
</section>
{{-- ── Frequent questions ───────────────────────────────────────
Each answer ends where the thing can actually be done. An FAQ
that only describes a button leaves the reader hunting for it. --}}
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="border-b border-line px-5 py-3.5">
<h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2>
</div>
<ul class="divide-y divide-line">
@foreach ($faqs as $i => $f)
<li>
<button type="button"
class="flex w-full items-center gap-3 px-5 py-3.5 text-left text-sm font-medium text-ink hover:bg-surface-hover"
@click="open = open === {{ $i }} ? null : {{ $i }}" :aria-expanded="open === {{ $i }}">
<span class="flex-1">{{ $f['q'] }}</span>
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition-transform"
::class="open === {{ $i }} ? 'rotate-180' : ''" />
</button>
<div x-show="open === {{ $i }}" x-cloak x-transition class="px-5 pb-4">
<p class="text-sm leading-relaxed text-muted">{{ $f['a'] }}</p>
@if ($f['to'])
<a href="{{ $f['to'] }}" wire:navigate
class="mt-2 inline-flex items-center gap-1.5 text-sm font-semibold text-accent-text hover:underline">
{{ $f['cta'] }}
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90" />
</a>
@endif
</div>
</li>
@endforeach
</ul>
</section>
</div>
{{-- ── Contact, and the one question an outage raises ───────────────
"Is it me or is it you" is what a customer asks first when
something is down, and it is answered by a page rather than by us. --}}
<aside class="space-y-3.5">
<section class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('support.contact') }}</h2>
<dl class="mt-3.5 space-y-3.5 text-sm">
<div>
<dt class="lbl">{{ __('support.contact_email') }}</dt>
<dd class="mt-1">
<a href="mailto:support@clupilot.com" class="font-medium text-accent-text hover:underline">support@clupilot.com</a>
</dd>
</div>
<div>
<dt class="lbl">{{ __('support.contact_hours') }}</dt>
<dd class="mt-1 text-body">{{ __('support.contact_hours_val') }}</dd>
</div>
<div>
<dt class="lbl">{{ __('support.contact_sla') }}</dt>
<dd class="mt-1 text-body">{{ __('support.contact_sla_val') }}</dd>
</div>
</dl>
</section>
<section class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('support.status_title') }}</h2>
<p class="mt-1.5 text-sm leading-relaxed text-muted">{{ __('support.status_body') }}</p>
<x-ui.button variant="secondary" size="sm" class="mt-3.5" :href="route('status')" target="_blank" rel="noopener">
<x-ui.icon name="external-link" class="size-4" />{{ __('support.status_cta') }}
</x-ui.button>
</section>
</aside>
</div> </div>
</div> </div>

View File

@ -44,9 +44,7 @@
<th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th> <th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th> <th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th> <th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
@if ($hasActions) <th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
@endif
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -71,16 +69,31 @@
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge> <x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
</td> </td>
@if ($hasActions)
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center justify-end gap-1.5"> <div class="flex items-center justify-end gap-1.5">
@if ($seat->role === 'owner')
<span class="text-muted" title="{{ __('users.owner_no_actions') }}"></span>
@endif
@if ($seat->status === 'invited') @if ($seat->status === 'invited')
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button> <button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
@endif @endif
@if ($seat->role !== 'owner')
{{-- Rename is offered on every row, the owner
included: a row you can do nothing to reads
as a broken table, not as a protected one.
It opens a modal, never the row itself
see R20. --}}
<button type="button"
x-on:click="$dispatch('openModal', { component: 'edit-seat', arguments: { uuid: '{{ $seat->uuid }}' } })"
aria-label="{{ __('users.edit') }}" title="{{ __('users.edit') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-ink hover:text-ink">
<x-ui.icon name="pencil" class="size-4" />
</button>
@if ($seat->role === 'owner')
{{-- Said, not hidden. The owner keeps their
own access; an empty cell would leave
somebody hunting for a button that is
deliberately absent. --}}
<span class="cursor-help text-xs text-muted" title="{{ __('users.owner_no_actions') }}">{{ __('users.owner_protected') }}</span>
@else
{{-- Pause first, remove second. Somebody {{-- Pause first, remove second. Somebody
leaving needs their access stopped leaving needs their access stopped
today; deleting the seat also throws today; deleting the seat also throws
@ -93,6 +106,7 @@
<x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" /> <x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" />
</button> </button>
<button type="button" wire:click="revoke('{{ $seat->uuid }}')" <button type="button" wire:click="revoke('{{ $seat->uuid }}')"
wire:confirm="{{ __('users.revoke_confirm', ['name' => $seat->name ?: $seat->email]) }}"
aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}" aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger"> class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" /> <x-ui.icon name="trash-2" class="size-4" />
@ -100,7 +114,6 @@
@endif @endif
</div> </div>
</td> </td>
@endif
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@ -38,3 +38,28 @@ Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/secrets', Admin\Secrets::class)->name('secrets'); Route::get('/secrets', Admin\Secrets::class)->name('secrets');
Route::get('/settings', Admin\Settings::class)->name('settings'); Route::get('/settings', Admin\Settings::class)->name('settings');
/*
* A deployment restarts the very application that is watching it.
*
* The settings card polls itself with wire:poll, which is fine until the run
* reaches the restart: the requests fail, and what comes back afterwards is a
* new build being asked questions by the old page's JavaScript. So the card
* never learned that the update had finished and an operator had to reload to
* find out reported exactly that way.
*
* This is the smallest thing that can answer "is it done, and is it still the
* same build" across that gap: no Livewire, no component state, no assets. A
* failed request here is expected (it IS the restart) and the poller keeps
* trying rather than giving up.
*/
Route::get('/update/state', function (\App\Services\Deployment\UpdateChannel $channel) {
$state = $channel->state();
return response()->json([
'running' => $state['running'],
'commit' => $state['commit'],
'behind' => $state['behind'],
'last_finished_at' => $state['last_finished_at']?->toIso8601String(),
])->header('Cache-Control', 'no-store');
})->name('update.state');

View File

@ -366,3 +366,69 @@ it('refuses a second request while the agent is mid-run', function () {
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
}); });
it('offers the update button even when the last check found nothing', function () {
// Reported as "I cannot run an update, it says everything is current".
// `behind` is a READING taken every few minutes, not the state of the
// world: push a commit and the console insisted it was up to date and
// refused to act on it. The agent fetches before deciding, so asking
// against a stale reading costs one fetch — being locked out costs the
// deployment.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_recheck'))
->call('requestUpdate');
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});
it('still refuses when there is no agent to pick the request up', function () {
// The one case where the button really would go nowhere: nothing is
// running that would ever read the request file.
writeStatus([
'checked_at' => now()->subDay()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertDontSee(__('admin_settings.update_recheck'));
});
it('watches the deployment in a way that survives the restart', function () {
// wire:poll cannot see the run through to the end, because the run
// restarts the containers answering the poll.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 2,
]);
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee('updateWatcher', escape: false);
});
it('answers the watcher without Livewire in the way', function () {
writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]);
$this->actingAs(operator('Owner'))
->get(route('admin.update.state'))
->assertOk()
->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at'])
->assertHeader('Cache-Control', 'no-store, private');
});
it('does not answer the watcher for somebody who is not an operator', function () {
$this->actingAs(\App\Models\User::factory()->create())
->get(route('admin.update.state'))
->assertForbidden();
});

View File

@ -0,0 +1,78 @@
<?php
use Illuminate\Support\Facades\File;
/**
* R20 editing with fields happens in a modal, never in the row.
*
* Inline editing was tried once, on the seats table. It worked and it looked
* broken: the row grew as the inputs appeared, the columns beside it jumped,
* and a table half in edit mode reads as a rendering fault rather than as a
* form. The project already had a modal for exactly this (EditDatacenter);
* the seats table simply did not use it.
*
* So the rule is mechanical: a page view that would grow input fields into a
* table row fails this test. Forms that ARE the page a create form, a
* settings panel, the invite row above a table are not editing an existing
* record in place and are untouched by this.
*/
/** Views that legitimately hold their own fields: they are forms, not rows. */
function isModalOrForm(string $path): bool
{
return str_contains($path, '/components/')
|| preg_match('#/livewire/(admin/)?(edit|confirm|create|new)-#', $path) === 1
|| preg_match('#/(auth|errors|mail)/#', $path) === 1;
}
it('never grows input fields inside a table row', function () {
$offenders = [];
foreach (File::allFiles(resource_path('views')) as $file) {
$path = str_replace(base_path().'/', '', $file->getPathname());
if (isModalOrForm($path)) {
continue;
}
$source = $file->getContents();
// Look only INSIDE <td>…</td>: a field in a table cell is an inline
// editor by definition. Fields elsewhere on the page are a form.
foreach (preg_split('#</td>#', $source) as $segment) {
$open = strrpos($segment, '<td');
// No cell opened in this segment — everything before the first
// <td> is page, not row. Without this the whole file counted as
// one cell and every ordinary form failed.
if ($open === false) {
continue;
}
$cell = substr($segment, $open);
if (preg_match('#<(input|textarea)\b(?![^>]*type="(checkbox|radio|hidden)")#', $cell, $m)) {
$offenders[$path][] = trim(strtok(ltrim($cell), "\n") ?: $m[0]);
}
}
}
expect($offenders)->toBe([]);
});
it('reaches every edit modal through openModal', function () {
// An edit button that calls a method on the page component instead of
// dispatching openModal is the inline editor coming back under a new name.
$modals = collect(File::allFiles(app_path('Livewire')))
->filter(fn ($f) => str_contains($f->getContents(), 'extends ModalComponent'))
->map(fn ($f) => $f->getFilenameWithoutExtension())
->values();
expect($modals)->toContain('EditSeat')
->and($modals)->toContain('EditDatacenter');
// And the seats table actually opens it.
$users = File::get(resource_path('views/livewire/users.blade.php'));
expect($users)->toContain("component: 'edit-seat'");
});

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Livewire\EditSeat;
use App\Livewire\Users; use App\Livewire\Users;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Instance; use App\Models\Instance;
@ -71,3 +72,109 @@ it('revokes a non-owner seat', function () {
Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid); Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse(); expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse();
}); });
it('shows the actions column even when the owner is the only seat', function () {
// Reported three times: a customer with one seat saw a table with nothing
// to click and concluded the product could not edit users. The column used
// to be hidden when there was "nothing to act on" — but a column that
// disappears does not read as "not applicable here", it reads as missing.
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
Livewire::actingAs($user)
->test(Users::class)
->assertSee(__('users.col_actions'))
->assertSee(__('users.edit'))
// And it says why the owner has no destructive actions instead of
// leaving an empty cell.
->assertSee(__('users.owner_protected'));
expect($customer->seats()->count())->toBe(1);
});
it('renames any seat, the owner included', function () {
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
$owner = $customer->seats()->create([
'email' => $user->email, 'name' => 'Falsch Geschrieben', 'role' => 'owner', 'status' => 'active',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $owner->uuid])
->assertSet('name', 'Falsch Geschrieben')
->assertSet('isOwner', true)
->set('name', 'Dr. M. Muster')
->call('save')
->assertHasNoErrors();
expect($owner->fresh()->name)->toBe('Dr. M. Muster');
});
it('corrects the address of an invitation still in flight', function () {
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
$seat = $customer->seats()->create([
'email' => 'tpyo@example.test', 'role' => 'member', 'status' => 'invited',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $seat->uuid])
->assertSet('addressEditable', true)
->set('email', 'typo@example.test')
->call('save')
->assertHasNoErrors();
expect($seat->fresh()->email)->toBe('typo@example.test');
});
it('will not move an accepted seat to a different address', function () {
// The address IS the person once they are in. Editing it would hand one
// employee's access to another with nobody told — a transfer of access
// wearing the clothes of a rename. Driven past the form on purpose: the
// property is hydrated from the browser and must not be trusted.
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
$seat = $customer->seats()->create([
'email' => 'in@example.test', 'name' => 'A', 'role' => 'member', 'status' => 'active',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $seat->uuid])
->assertSet('addressEditable', false)
->set('addressEditable', true)
->set('email', 'someone.else@example.test')
->set('name', 'B')
->call('save');
expect($seat->fresh()->email)->toBe('in@example.test')
->and($seat->fresh()->name)->toBe('B');
});
it('refuses to open a seat belonging to somebody else', function () {
// The uuid comes from the browser, and a modal is reachable without the
// page's route middleware.
$mine = User::factory()->create();
Customer::factory()->create(['user_id' => $mine->id, 'email' => $mine->email]);
$theirs = Customer::factory()->create(['user_id' => User::factory()->create()->id]);
$seat = $theirs->seats()->create(['email' => 'x@example.test', 'name' => 'X', 'role' => 'member', 'status' => 'active']);
Livewire::actingAs($mine)
->test(EditSeat::class, ['uuid' => $seat->uuid])
->assertStatus(404);
expect($seat->fresh()->name)->toBe('X');
});
it('opens editing in a modal rather than in the row', function () {
// R20. Inline fields made the row grow and the columns beside it jump,
// which reads as a rendering fault rather than as a form.
$user = User::factory()->create();
Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
Livewire::actingAs($user)
->test(Users::class)
->assertSee('openModal', escape: false)
->assertSee('edit-seat', escape: false)
->assertDontSee('wire:model="editName"', escape: false);
});

View File

@ -0,0 +1,164 @@
<?php
use App\Livewire\NewSupportRequest;
use App\Livewire\Support;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\SupportRequest;
use App\Models\User;
use Livewire\Livewire;
/**
* Asking for help, and being able to see that you did.
*
* The page used to be a decorated placeholder: a button that raised a toast
* saying the form was "only hinted at in the prototype", three invented ticket
* titles sitting in the translation file, and no way to check what had become
* of anything. It looked thin because nothing on it was real.
*/
function supportCustomer(): array
{
$user = User::factory()->create(['name' => 'Dr. S. Berger']);
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
return [$user, $customer];
}
it('records a request and shows it back to the customer', function () {
[$user, $customer] = supportCustomer();
Livewire::actingAs($user)
->test(NewSupportRequest::class)
->set('category', 'technical')
->set('subject', 'Kalendersynchronisation mit Outlook')
->set('body', 'Termine werden bei zwei Mitarbeitern nicht mehr übernommen.')
->call('save')
->assertHasNoErrors();
$request = SupportRequest::query()->where('customer_id', $customer->id)->sole();
expect($request->status)->toBe('open')
// The person, not the account: on a shared login the account name says
// nothing about who is actually asking.
->and($request->reported_by)->toBe('Dr. S. Berger');
Livewire::actingAs($user)
->test(Support::class)
->assertSee('Kalendersynchronisation mit Outlook')
->assertSee(__('support.status_open'));
});
it('attaches the instance so nobody has to ask which server', function () {
[$user, $customer] = supportCustomer();
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);
Livewire::actingAs($user)
->test(NewSupportRequest::class)
->set('subject', 'Ein Betreff')
->set('body', 'Eine Nachricht, die lang genug ist.')
->call('save');
expect(SupportRequest::query()->sole()->instance_id)->toBe($instance->id);
});
it('still takes a request from somebody whose cloud is not built yet', function () {
// A question can arrive before provisioning finishes, and that is exactly
// when somebody is most likely to have one.
[$user] = supportCustomer();
Livewire::actingAs($user)
->test(NewSupportRequest::class)
->set('subject', 'Wann geht es los?')
->set('body', 'Wir haben gestern bestellt und würden gerne planen.')
->call('save')
->assertHasNoErrors();
expect(SupportRequest::query()->sole()->instance_id)->toBeNull();
});
it('refuses an empty or barely-written request', function () {
[$user] = supportCustomer();
Livewire::actingAs($user)
->test(NewSupportRequest::class)
->set('subject', '')
->set('body', 'kurz')
->call('save')
->assertHasErrors(['subject', 'body']);
expect(SupportRequest::query()->count())->toBe(0);
});
it('refuses a category that is not on the form', function () {
[$user] = supportCustomer();
Livewire::actingAs($user)
->test(NewSupportRequest::class)
->set('category', 'urgent-vip')
->set('subject', 'Ein Betreff')
->set('body', 'Eine Nachricht, die lang genug ist.')
->call('save')
->assertHasErrors('category');
});
it('shows a customer only their own requests', function () {
[$user] = supportCustomer();
[, $other] = supportCustomer();
SupportRequest::create([
'customer_id' => $other->id,
'subject' => 'Fremde Anfrage',
'category' => 'billing',
'body' => 'Gehört jemand anderem.',
'status' => 'open',
]);
Livewire::actingAs($user)
->test(Support::class)
->assertDontSee('Fremde Anfrage')
->assertSee(__('support.empty_title'));
});
it('counts only what is still open', function () {
[$user, $customer] = supportCustomer();
foreach (['open', 'in_progress', 'answered', 'closed'] as $status) {
SupportRequest::create([
'customer_id' => $customer->id,
'subject' => 'Anfrage '.$status,
'category' => 'other',
'body' => 'Eine Nachricht, die lang genug ist.',
'status' => $status,
]);
}
Livewire::actingAs($user)
->test(Support::class)
// open + in_progress. Answered and closed are done with.
->assertViewHas('openCount', 2);
});
it('says something useful when there is nothing to show', function () {
// An empty state that names the next step, rather than "no data".
[$user] = supportCustomer();
Livewire::actingAs($user)
->test(Support::class)
->assertSee(__('support.empty_title'))
->assertSee(__('support.empty_body'))
->assertSee(__('support.new'));
});
it('carries no invented ticket titles', function () {
// Three fictional tickets used to live in the translation file and render
// as though they were the customer's own.
foreach (['de', 'en'] as $locale) {
$keys = array_keys(include base_path("lang/{$locale}/support.php"));
expect($keys)->not->toContain('ticket_migration')
->and($keys)->not->toContain('ticket_training')
->and($keys)->not->toContain('ticket_smtp')
->and($keys)->not->toContain('new_toast');
}
});