From 4e8f17189a82186a799641aab57f0af483fefd69 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 22 Jul 2026 08:50:03 +0200 Subject: [PATCH] v1.0.2 - 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 --- .gitea/workflows/build.yml | 9 +++- CHANGELOG.md | 23 ++++++++++ install.md | 19 ++++++++- src/.env.example | 2 +- src/app/Livewire/Auth/Login.php | 10 +++++ src/app/Models/Ticket.php | 9 ++++ src/app/Services/TicketService.php | 13 +++++- .../views/livewire/auth/login.blade.php | 4 +- .../livewire/operator/ticket-show.blade.php | 4 +- .../ExtendedNotificationTriggersTest.php | 24 +++++++++-- src/tests/Feature/LdapLoginTest.php | 21 ++++++++++ src/tests/Feature/TicketTimeTrackingTest.php | 42 +++++++++++++++++++ wiki/admin/README.md | 3 ++ wiki/operator/README.md | 5 ++- 14 files changed, 174 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 92202f4..0a9d1f7 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -24,12 +24,19 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Verify registry credentials are configured + run: | + if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then + echo "::error::Secret REGISTRY_TOKEN is not set, so login to gitea.kzbikowski.pl would fail. Create a Gitea access token with 'write:package' (and 'read:package') scope — user Settings > Applications > Generate New Token — then add it as an Actions secret named REGISTRY_TOKEN under this repo's Settings > Actions > Secrets. Aborting before attempting login." >&2 + exit 1 + fi + - name: Log in to Gitea container registry uses: docker/login-action@v3 with: registry: gitea.kzbikowski.pl username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 60005b0..84b43c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.0.2] - 2026-07-22 + +- Fixed `.gitea/workflows/build.yml`: registry login was failing with + `unauthorized` because Gitea's auto-injected `secrets.GITHUB_TOKEN` isn't + granted push access to its own container registry on this instance. Now + uses a dedicated `REGISTRY_TOKEN` secret (a Gitea access token with + `write:package`/`read:package` scope), and the workflow fails fast with a + clear `::error::` message before attempting login if that secret isn't + configured, instead of surfacing Docker's opaque `unauthorized` error. +- Fixed: opening or manually resuming a **closed** ticket no longer starts its + work timer, and closing a ticket (via the status dropdown, a reply "quick + action" transition, the REST API, or merge) now checkpoints and stops any + running timer. Time tracking only ever accrues while a ticket is open. +- Fixed: the login form accepted an empty username/password, submitting them + straight to the auth provider. The form fields are now `required` (blocks + submission client-side) and `Login::submit()` also rejects blank/ + whitespace-only credentials server-side before attempting authentication, + showing "Podaj nazwę użytkownika i hasło." instead. +- Fixed: closing a ticket sent two separate notification e-mails + (`status_changed` and `ticket_closed`) for the same event. Closing now only + fires `ticket_closed`; every other status transition still fires + `status_changed` as before. + ## [1.0.1] - 2026-07-22 Documentation and deployment/CI overhaul — no application behavior changes. diff --git a/install.md b/install.md index 3457101..0c03363 100644 --- a/install.md +++ b/install.md @@ -74,7 +74,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji -VERSION=1.0.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane +VERSION=1.0.2 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane DB_CONNECTION=mysql DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 @@ -136,6 +136,21 @@ kontenerów Gitea (`gitea.kzbikowski.pl/kzbkowski/servicedesk`) po każdym pushu przebudowa dla samego kodu byłaby marnowaniem czasu CI). Wypycha dwa tagi: `latest` i ``. +Zanim to zadziała, workflow potrzebuje sekretu `REGISTRY_TOKEN` — Gitei **nie** +ufaj domyślnemu `secrets.GITHUB_TOKEN` do logowania w jej własnym rejestrze +kontenerów, w praktyce kończy się to błędem `unauthorized` przy +`docker login`. Zamiast tego: + +1. Wygeneruj token: **Ustawienia użytkownika > Aplikacje > Generate New Token**, + z uprawnieniami co najmniej `write:package` i `read:package`. +2. Dodaj go jako sekret repo: **Ustawienia repo > Actions > Secrets** → + nazwa `REGISTRY_TOKEN`, wartość = wygenerowany token. + +Jeśli ten sekret nie jest ustawiony, workflow celowo przerywa się **przed** +próbą logowania z czytelnym komunikatem błędu (`::error::`), zamiast wysyłać +puste/nieautoryzowane dane do rejestru i kończyć na niejasnym `unauthorized` z +demona Dockera. + To **tylko build + push** — świadomie bez auto-deployu na produkcję. Po tym jak CI skończy, wdrożenie nowego obrazu na serwerze wciąż jest ręcznym krokiem: @@ -260,7 +275,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl -VERSION=1.0.0 +VERSION=1.0.2 DB_CONNECTION=mysql DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB diff --git a/src/.env.example b/src/.env.example index 40c1c79..e89b668 100644 --- a/src/.env.example +++ b/src/.env.example @@ -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 diff --git a/src/app/Livewire/Auth/Login.php b/src/app/Livewire/Auth/Login.php index eab3300..38f7ce1 100644 --- a/src/app/Livewire/Auth/Login.php +++ b/src/app/Livewire/Auth/Login.php @@ -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 diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 86e3067..5e50e5f 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -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()]); } diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php index e7d14f0..6af67c3 100644 --- a/src/app/Services/TicketService.php +++ b/src/app/Services/TicketService.php @@ -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, diff --git a/src/resources/views/livewire/auth/login.blade.php b/src/resources/views/livewire/auth/login.blade.php index 5791a52..a36ff18 100644 --- a/src/resources/views/livewire/auth/login.blade.php +++ b/src/resources/views/livewire/auth/login.blade.php @@ -15,11 +15,11 @@
- +
- +
diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index 1f931e7..62d0e0d 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -345,7 +345,9 @@ edit
- @if ($ticket->timer_started_at) + @if ($ticket->isClosed()) + Zgłoszenie zamknięte — zliczanie wstrzymane + @elseif ($ticket->timer_started_at) @else diff --git a/src/tests/Feature/ExtendedNotificationTriggersTest.php b/src/tests/Feature/ExtendedNotificationTriggersTest.php index 7841da4..c71eb61 100644 --- a/src/tests/Feature/ExtendedNotificationTriggersTest.php +++ b/src/tests/Feature/ExtendedNotificationTriggersTest.php @@ -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 () { diff --git a/src/tests/Feature/LdapLoginTest.php b/src/tests/Feature/LdapLoginTest.php index afde671..f2eafcf 100644 --- a/src/tests/Feature/LdapLoginTest.php +++ b/src/tests/Feature/LdapLoginTest.php @@ -1,10 +1,12 @@ '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(); +}); diff --git a/src/tests/Feature/TicketTimeTrackingTest.php b/src/tests/Feature/TicketTimeTrackingTest.php index f38114b..72686d1 100644 --- a/src/tests/Feature/TicketTimeTrackingTest.php +++ b/src/tests/Feature/TicketTimeTrackingTest.php @@ -1,6 +1,7 @@ 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'); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index 6605d63..34a6305 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -83,6 +83,9 @@ więcej informacji”, „Restart usuwa problem”. odpowiedział, SLA przekroczone) — każde ma przełącznik włącz/wyłącz, odbiorcę (klient / operator) i przypisany szablon. Usunięcie przypisanego szablonu po prostu wyłącza wysyłkę tego powiadomienia, dopóki ktoś nie wybierze nowego. + „Zmiana statusu” i „zgłoszenie zamknięte” się wzajemnie wykluczają dla tej + samej zmiany — zamknięcie zgłoszenia wysyła wyłącznie powiadomienie + „zgłoszenie zamknięte”, żeby nie dublować maila. ## Wygląd / Branding diff --git a/wiki/operator/README.md b/wiki/operator/README.md index 694beeb..e1b71d3 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -43,7 +43,10 @@ W widoku pojedynczego zgłoszenia: - **Załączniki** — do odpowiedzi/notatki, w granicach limitów ustawionych przez administratora. - **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się - automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`). + automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`). Zliczanie + jest automatycznie wstrzymywane, gdy zgłoszenie ma status zamknięty — nie + uruchomi się przy otwarciu zamkniętego zgłoszenia ani nie będzie dalej biec + po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować. - **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe; zmiana kategorii może wysłać powiadomienie do klienta. - **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z