diff --git a/CLAUDE.md b/CLAUDE.md index fe05960..57d1e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,3 +106,47 @@ darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten, Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()` verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe 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 ``, kein + ` + @error('body')

{{ $message }}

@enderror + + + {{-- Said once, plainly. The alternative is a form that asks the customer + to describe their own server back to the people who built it. --}} +

{{ __('support.attached_hint') }}

+ + +
+ {{ __('common.cancel') }} + {{ __('support.send') }} +
+ diff --git a/resources/views/livewire/support.blade.php b/resources/views/livewire/support.blade.php index 16c7a82..28871b4 100644 --- a/resources/views/livewire/support.blade.php +++ b/resources/views/livewire/support.blade.php @@ -1,45 +1,138 @@ -
-
-
-

{{ __('support.title') }}

-

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

+@php + $locale = app()->getLocale(); +@endphp +
+
+
+

{{ __('support.eyebrow') }}

+

+ {{ __('support.title') }} +

- + + {{ __('support.new') }} -
+ - {{-- Contact --}} - @php $delays = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]']; @endphp -
- @foreach ([ - ['life-buoy', __('support.contact_hours'), __('support.contact_hours_val')], - ['external-link', __('support.contact_email'), 'support@clupilot.com'], - ['shield-check', __('support.contact_sla'), __('support.contact_sla_val')], - ] as $i => [$icon, $label, $val]) -
- -
-

{{ $label }}

-

{{ $val }}

+
+ {{-- ── The customer's own requests ────────────────────────────────── + The page used to lead with three cards about opening hours. What + somebody arriving here actually wants to know is what they already + asked and whether anybody has answered — so that goes first, and + the contact details move to the side where they belong. --}} +
+
+
+

{{ __('support.tickets') }}

+ @if ($openCount > 0) + + {{ trans_choice('support.open_count', $openCount, ['count' => $openCount]) }} + + @endif
-
- @endforeach -
- {{-- Tickets --}} -
-

{{ __('support.faq') }}

-
    - @foreach ($faqs as $i => $f) -
  • - -
    {{ $f['a'] }}
    -
  • - @endforeach -
+ @forelse ($requests as $r) +
+
+

{{ $r->subject }}

+

+ {{ __('support.category_'.$r->category) }} + · {{ $r->created_at->local()->locale($locale)->isoFormat('LL, LT') }} + @if ($r->reported_by) · {{ $r->reported_by }} @endif +

+ @if ($r->answered_at) +

+ {{ __('support.answered_at', ['when' => $r->answered_at->local()->locale($locale)->isoFormat('LL, LT')]) }} +

+ @endif +
+ + @php $tone = ['open' => 'info', 'in_progress' => 'provisioning', 'answered' => 'active', 'closed' => 'suspended'][$r->status] ?? 'info'; @endphp + {{ __('support.status_'.$r->status) }} +
+ @empty + {{-- An empty state that says what to do, not "no data". --}} +
+ + + +

{{ __('support.empty_title') }}

+

{{ __('support.empty_body') }}

+ + {{ __('support.new') }} + +
+ @endforelse + + + {{-- ── 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. --}} +
+
+

{{ __('support.faq') }}

+
+
    + @foreach ($faqs as $i => $f) +
  • + +
    +

    {{ $f['a'] }}

    + @if ($f['to']) + + {{ $f['cta'] }} + + + @endif +
    +
  • + @endforeach +
+
+
+ + {{-- ── 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. --}} +
diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 3d7e2c6..667fddf 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -44,9 +44,7 @@ {{ __('users.col_person') }} {{ __('users.role') }} {{ __('users.col_status') }} - @if ($hasActions) - {{ __('users.col_actions') }} - @endif + {{ __('users.col_actions') }} @@ -71,16 +69,31 @@ @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp {{ __('users.status_'.$seat->status) }} - @if ($hasActions)
- @if ($seat->role === 'owner') - - @endif @if ($seat->status === 'invited') @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. --}} + + + @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. --}} + {{ __('users.owner_protected') }} + @else {{-- Pause first, remove second. Somebody leaving needs their access stopped today; deleting the seat also throws @@ -93,6 +106,7 @@
- @endif @endforeach diff --git a/routes/admin.php b/routes/admin.php index f7ee72d..79bfce7 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -38,3 +38,28 @@ Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/secrets', Admin\Secrets::class)->name('secrets'); 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'); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index c01b0c6..06fa102 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -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(); }); + +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(); +}); diff --git a/tests/Feature/EditInModalTest.php b/tests/Feature/EditInModalTest.php new file mode 100644 index 0000000..2b7187a --- /dev/null +++ b/tests/Feature/EditInModalTest.php @@ -0,0 +1,78 @@ +getPathname()); + + if (isModalOrForm($path)) { + continue; + } + + $source = $file->getContents(); + + // Look only INSIDE …: a field in a table cell is an inline + // editor by definition. Fields elsewhere on the page are a form. + foreach (preg_split('##', $source) as $segment) { + $open = strrpos($segment, ' 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'"); +}); diff --git a/tests/Feature/SeatsTest.php b/tests/Feature/SeatsTest.php index ed0a48e..78790cf 100644 --- a/tests/Feature/SeatsTest.php +++ b/tests/Feature/SeatsTest.php @@ -1,5 +1,6 @@ test(Users::class)->call('revoke', $seat->uuid); 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); +}); diff --git a/tests/Feature/SupportRequestTest.php b/tests/Feature/SupportRequestTest.php new file mode 100644 index 0000000..8bf34c5 --- /dev/null +++ b/tests/Feature/SupportRequestTest.php @@ -0,0 +1,164 @@ +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'); + } +});