v1.0.2
All checks were successful
Build and push image / build (push) Successful in 1m24s

- Fix CI registry login (unauthorized): use dedicated REGISTRY_TOKEN secret
  instead of GITHUB_TOKEN, fail fast with a clear error if it's unset.
- Pause ticket work-timer while a ticket is closed (won't auto-start on open
  or manual resume; stops on close via status change, quick action, API, or
  merge).
- Reject empty/blank login submissions client- and server-side instead of
  passing them straight to the auth provider.
- Closing a ticket now sends only the "ticket closed" notification instead
  of also sending a duplicate "status changed" one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 08:50:03 +02:00
parent 13a758779d
commit 4e8f17189a
14 changed files with 174 additions and 14 deletions

View File

@@ -5,7 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.0.1
VERSION=1.0.2
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View File

@@ -27,6 +27,16 @@ class Login extends Component
{
$this->error = null;
// The form also has HTML `required` attributes so the browser blocks
// an empty submit before it ever reaches here, but that's only a UX
// nicety — nothing stops a request hitting this method directly, so
// it needs to fail closed on its own too.
if (trim($this->username) === '' || $this->password === '') {
$this->error = 'Podaj nazwę użytkownika i hasło.';
return;
}
$attribute = Settings::ldapUsernameAttribute();
// Local accounts (created with a password from the admin panel) don't

View File

@@ -283,8 +283,17 @@ class Ticket extends Model
}
}
/**
* No-ops on a closed ticket time tracking only applies to open work,
* so a closed ticket's timer should never start (whether via auto-resume
* on open or the manual "Wznów" button).
*/
public function resumeTimer(): void
{
if ($this->isClosed()) {
return;
}
if (! $this->timer_started_at) {
$this->update(['timer_started_at' => now()]);
}

View File

@@ -75,10 +75,18 @@ class TicketService
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
$this->notify($ticket, 'status_changed');
// A transition to "closed" fires its own dedicated notification
// instead of the generic status-changed one, so closing a ticket
// doesn't send the customer/operator two emails for one event.
if ($statusKey === 'closed') {
$this->notify($ticket, 'ticket_closed');
// Time tracking only applies to open work — checkpoint and pause
// the running segment (if any) the moment a ticket is closed,
// regardless of which flow triggered the status change.
$ticket->stopTimer();
} else {
$this->notify($ticket, 'status_changed');
}
}
@@ -253,6 +261,7 @@ class TicketService
}
$other->update(['status_key' => 'closed']);
$other->stopTimer();
$note = $other->messages()->create([
'author_name' => 'System',
'internal' => true,

View File

@@ -15,11 +15,11 @@
<div class="field">
<label>Nazwa użytkownika</label>
<input class="input" wire:model="username" autofocus>
<input class="input" wire:model="username" autofocus required>
</div>
<div class="field">
<label>Hasło</label>
<input class="input" type="password" wire:model="password">
<input class="input" type="password" wire:model="password" required>
</div>
<button class="btn btn-primary btn-block" type="submit">Zaloguj się</button>
</form>

View File

@@ -345,7 +345,9 @@
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
</div>
<div style="display:flex;gap:6px">
@if ($ticket->timer_started_at)
@if ($ticket->isClosed())
<span style="font-size:12px;opacity:0.7">Zgłoszenie zamknięte zliczanie wstrzymane</span>
@elseif ($ticket->timer_started_at)
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button>
@else
<button type="button" class="btn btn-secondary" wire:click="resumeTimer" @click="running = true; start()">Wznów</button>

View File

@@ -90,14 +90,14 @@ test('changing the subcategory fires category_changed, but re-saving details wit
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('closing a ticket fires both status_changed and ticket_closed', function () {
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
Notification::fake();
seedStatusesAndPriorities();
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
// status_changed ships enabled by default, but in a bare migrated (unseeded)
// database it has no template assigned yet — give it one so both triggers
// actually have something to send, isolating this test from seeding order.
// database it has no template assigned yet — give it one so it would have
// something to send if it (wrongly) fired, isolating this test from seeding order.
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
@@ -107,7 +107,23 @@ test('closing a ticket fires both status_changed and ticket_closed', function ()
app(TicketService::class)->setStatus($ticket, 'closed');
Notification::assertSentOnDemandTimes(TicketNotification::class, 2);
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('a non-closing status change still fires status_changed as usual', function () {
Notification::fake();
seedStatusesAndPriorities();
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['email_template_id' => $statusTemplate->id]);
$ticket = makeTicket();
app(TicketService::class)->setStatus($ticket, 'open');
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {

View File

@@ -1,10 +1,12 @@
<?php
use App\Ldap\LldapUser;
use App\Livewire\Auth\Login;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use LdapRecord\Laravel\Testing\DirectoryEmulator;
use Livewire\Livewire;
afterEach(function () {
DirectoryEmulator::tearDown();
@@ -63,3 +65,22 @@ test('an unknown username does not authenticate', function () {
expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse();
});
test('submitting the login form with a blank username or password shows an error and never attempts to authenticate', function () {
Livewire::test(Login::class)
->set('username', '')
->set('password', '')
->call('submit')
->assertSet('error', 'Podaj nazwę użytkownika i hasło.');
expect(Auth::check())->toBeFalse();
// Whitespace-only counts as blank for the username too.
Livewire::test(Login::class)
->set('username', ' ')
->set('password', 'somepassword')
->call('submit')
->assertSet('error', 'Podaj nazwę użytkownika i hasło.');
expect(Auth::check())->toBeFalse();
});

View File

@@ -1,6 +1,7 @@
<?php
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Services\TicketService;
use Livewire\Livewire;
test('opening a ticket for the first time auto-starts the timer', function () {
@@ -186,6 +187,47 @@ test('the stop-timer beacon endpoint checkpoints and stops a running timer', fun
->and($ticket->time_spent_seconds)->toBe(50);
});
test('opening a closed ticket does not auto-start the timer', function () {
seedStatusesAndPriorities();
$operator = operatorUser('timer-closed-open@example.com');
$ticket = makeTicket(['status_key' => 'closed', 'time_spent_seconds' => 30, 'timer_started_at' => null]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
$ticket->refresh();
expect($ticket->timer_started_at)->toBeNull()
->and($ticket->time_spent_seconds)->toBe(30);
});
test('manually resuming a closed ticket does not start the timer', function () {
seedStatusesAndPriorities();
$operator = operatorUser('timer-closed-resume@example.com');
$ticket = makeTicket(['status_key' => 'closed', 'timer_started_at' => null]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->call('resumeTimer');
expect($ticket->fresh()->timer_started_at)->toBeNull();
});
test('closing a ticket via TicketService::setStatus checkpoints and stops a running timer', function () {
seedStatusesAndPriorities();
$this->travelTo(now());
$operator = operatorUser('timer-close-via-status@example.com');
$ticket = makeTicket();
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
$ticket->refresh();
$this->travel(70)->seconds();
app(TicketService::class)->setStatus($ticket, 'closed');
$ticket->refresh();
expect($ticket->timer_started_at)->toBeNull()
->and($ticket->time_spent_seconds)->toBe(70);
});
test('cancelling the timer edit leaves the tracked time untouched', function () {
seedStatusesAndPriorities();
$operator = operatorUser('timer-edit-cancel@example.com');