Co nowego:
- Instalator przy pierwszym uruchomieniu (/setup): świeża instalacja nie ma
  żadnego konta, więc każda strona prowadzi do instalatora. Trzy drogi:
  podłączenie LDAP/AD (z testem połączenia) i zalogowanie się kontem z
  katalogu, które zostaje administratorem; założenie lokalnego konta
  administratora; albo import pliku ustawień z innej instalacji. Instalator
  zamyka się na stałe, gdy powstanie pierwsze konto.

Zmiany:
- Seeder nie tworzy już konta admin@example.com / admin, a LDAP zostawia
  pusty i wyłączony zamiast przykładowego ldap.example.com.
- Komunikat o nieudanym logowaniu jest pełnoszerokim boksem zamiast małej
  etykiety.
- Dokumentacja opisuje instalator; poprawione pozostałe ścieżki src/ w
  install.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rz61PznDTKUACA7sVTB8H3
This commit is contained in:
kzbkowskiandClaude Opus 5.5 committed 2026-10-01 13:23:18 +02:00
1 parent efff0ddfa0
commit 0de22dbce2
24 files changed
+727 -97

No files matched your search

+9
View File
@@ -34,6 +34,15 @@ which makes its healthcheck fail and blocks every dependent container — if
the data dir is ever recreated, `sudo chmod 600` that file (and `chmod 750
docker/mysql`) before `up -d`.
## First-run installer
A database with no users and no `setup_completed` setting is a fresh install:
`RedirectToSetup` (prepended to the `web` group) sends every page to `/setup`
(`App\Livewire\Setup`), which is unauthenticated. `App\Support\Setup::required()`
is the single gate — every installer action re-checks it, and any new one must
too. The seeder creates no account; tests run with `setup_completed` set by
`tests/TestCase.php`, so a test that needs the installer turns it back off.
## This is a live production system
The only real user today is the owner's own account (client+operator+admin
+6 -4
View File
@@ -259,10 +259,12 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
sudo docker exec servicedesk-app-1 php artisan migrate:fresh --seed
```
Seeds real reference data (categories, custom fields, statuses/priorities/SLA,
teams, quick actions, response/e-mail templates, branding/config with example
SMTP+LDAP placeholders) and a single local fallback account, **`admin@example.com`
/ `admin`** — replace its password and/or the SMTP/LDAP settings before relying on
this in anything but a lab environment.
teams, quick actions, response/e-mail templates, branding/config with an example
SMTP placeholder) and **no accounts**. With no account in the database every page
redirects to the first-run installer at `/setup`, which either connects LDAP/AD and
makes the person logging in the administrator, creates a local administrator, or
imports a settings file from another install. It is unauthenticated until that
first account exists — complete it right after deploying.
## Project layout
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use App\Support\Setup;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Until the first-run installer has been completed, every page leads to it
* — a fresh install has no account to log in with, so the login form (or
* anything else) would be a dead end. Livewire's own endpoints are let
* through because the installer itself talks to them.
*/
class RedirectToSetup
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->routeIs('setup') && ! $request->is('livewire*') && Setup::required()) {
return redirect()->route('setup');
}
return $next($request);
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
namespace App\Livewire;
use App\Models\User;
use App\Services\ConfigurationTransfer;
use App\Support\Settings;
use App\Support\Setup as SetupState;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use LdapRecord\Connection;
use Livewire\Component;
use Livewire\WithFileUploads;
use RuntimeException;
/**
* First-run installer: the only page a fresh install serves until someone
* can log in. Three ways out — point it at LDAP/AD and sign in (that person
* becomes the first administrator), create a local administrator, or import
* a settings file exported from another install.
*
* Nobody is authenticated here, so every action re-checks that setup is
* still required before doing anything: once an account exists these
* methods must be dead, even for a client replaying an old page.
*/
class Setup extends Component
{
use WithFileUploads;
public string $mode = 'ldap';
public array $ldap = [
'directoryType' => 'lldap',
'host' => '',
'port' => '389',
'useSsl' => false,
'baseDn' => '',
'bindDn' => '',
'bindPassword' => '',
'userFilter' => '',
'username' => '',
'password' => '',
];
public ?string $ldapTestResult = null;
public ?string $ldapTestMessage = null;
public array $local = ['name' => '', 'email' => '', 'password' => '', 'password_confirmation' => ''];
public $importFile = null;
public ?string $error = null;
public function mount()
{
if (! SetupState::required()) {
return $this->redirect(route('login'));
}
}
public function setMode(string $mode): void
{
if (in_array($mode, ['ldap', 'local', 'import'], true)) {
$this->mode = $mode;
$this->error = null;
}
}
public function testLdap(): void
{
$this->guard();
$this->validateLdapConnection();
$failure = $this->ldapConnectionFailure();
$this->ldapTestResult = $failure === null ? 'ok' : 'error';
$this->ldapTestMessage = $failure;
}
/**
* Saves the connection, then signs in with the given directory account
* through the normal login path — which also creates the local user row
* — and makes that account the administrator. Nothing is marked as done
* unless that login actually works: a saved-but-wrong LDAP config with
* no way in is exactly the state the installer exists to prevent.
*/
public function finishWithLdap(): void
{
$this->guard();
$this->error = null;
$this->validateLdapConnection();
$this->validate(
['ldap.username' => 'required|string', 'ldap.password' => 'required|string'],
[],
['ldap.username' => 'nazwa użytkownika', 'ldap.password' => 'hasło'],
);
if (($failure = $this->ldapConnectionFailure()) !== null) {
$this->error = 'Nie udało się połączyć z serwerem LDAP: '.$failure;
return;
}
Settings::set('ldap_enabled', '1');
Settings::set('ldap_directory_type', $this->ldap['directoryType'] === 'ad' ? 'ad' : 'lldap');
Settings::set('ldap_host', trim($this->ldap['host']));
Settings::set('ldap_port', (string) (int) $this->ldap['port']);
Settings::set('ldap_base_dn', trim($this->ldap['baseDn']));
Settings::set('ldap_bind_dn', trim($this->ldap['bindDn']));
Settings::set('ldap_bind_password', $this->ldap['bindPassword']);
Settings::set('ldap_use_ssl', $this->ldap['useSsl'] ? '1' : '0');
Settings::set('ldap_user_filter', trim($this->ldap['userFilter']) ?: $this->defaultUserFilter());
// The connection and user model were wired up at boot, from the
// settings as they were before this request changed them.
Settings::applyLdapConnection();
Auth::forgetGuards();
$ok = false;
try {
$ok = Auth::attempt([
Settings::ldapUsernameAttribute() => $this->ldap['username'],
'password' => $this->ldap['password'],
]);
} catch (\Throwable $e) {
report($e);
}
if (! $ok) {
$this->ldap['password'] = '';
$this->error = 'Połączenie z serwerem działa, ale logowanie tym kontem się nie powiodło. Sprawdź nazwę użytkownika i hasło, a także Base DN i filtr użytkownika (konto musi znajdować się pod Base DN i pasować do filtra).';
return;
}
$user = Auth::user();
$user->roles = ['client', 'operator', 'admin'];
$user->save();
$this->finish();
}
public function finishWithLocalAdmin(): void
{
$this->guard();
$this->error = null;
$this->validate(
[
'local.name' => 'required|string|max:255',
'local.email' => 'required|email|max:255',
'local.password' => ['required', 'confirmed', Password::min(8)],
],
[],
['local.name' => 'imię i nazwisko', 'local.email' => 'e-mail', 'local.password' => 'hasło'],
);
$user = User::query()->create([
'name' => $this->local['name'],
'email' => $this->local['email'],
'password' => $this->local['password'],
'roles' => ['client', 'operator', 'admin'],
]);
// Seeded as "on" with example values; without a real server every
// login would first wait on an LDAP lookup that can't succeed.
Settings::set('ldap_enabled', '0');
Settings::set('ldap_host', '');
Auth::login($user);
$this->finish();
}
public function import(ConfigurationTransfer $transfer): void
{
$this->guard();
$this->error = null;
$this->validate(
['importFile' => 'required|file|extensions:json|max:51200'],
['importFile.*' => 'Wybierz plik .json utworzony przez eksport ustawień (maks. 50 MB).'],
);
try {
$transfer->importJson($this->importFile->get());
} catch (RuntimeException $e) {
$this->error = $e->getMessage();
return;
}
if (! User::query()->exists()) {
$this->importFile = null;
$this->error = 'Ustawienia zostały zaimportowane, ale plik nie zawierał żadnego konta użytkownika — utwórz administratora lub skonfiguruj LDAP, żeby dokończyć instalację.';
return;
}
SetupState::complete();
$this->redirect(route('login'));
}
public function render()
{
return view('livewire.setup')->title(Settings::pageTitle('Instalacja'));
}
private function guard(): void
{
abort_unless(SetupState::required(), 404);
}
private function finish(): void
{
SetupState::complete();
session()->regenerate();
$this->redirect(route('admin.panel'));
}
private function defaultUserFilter(): string
{
return $this->ldap['directoryType'] === 'ad' ? '(sAMAccountName={0})' : '(uid={0})';
}
private function validateLdapConnection(): void
{
$this->validate(
[
'ldap.host' => 'required|string',
'ldap.port' => 'required|integer|between:1,65535',
'ldap.baseDn' => 'required|string',
'ldap.bindDn' => 'required|string',
'ldap.bindPassword' => 'required|string',
],
[],
['ldap.host' => 'host', 'ldap.port' => 'port', 'ldap.baseDn' => 'Base DN', 'ldap.bindDn' => 'Bind DN', 'ldap.bindPassword' => 'hasło Bind'],
);
}
/** Null when the bind account can connect, otherwise the reason it can't. */
private function ldapConnectionFailure(): ?string
{
try {
(new Connection([
'hosts' => [trim($this->ldap['host'])],
'port' => (int) $this->ldap['port'],
'base_dn' => trim($this->ldap['baseDn']),
'username' => trim($this->ldap['bindDn']),
'password' => $this->ldap['bindPassword'],
'use_tls' => (bool) $this->ldap['useSsl'],
'timeout' => 5,
]))->connect();
return null;
} catch (\Throwable $e) {
return $e->getMessage();
}
}
}
+1 -24
View File
@@ -18,8 +18,6 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use LdapRecord\Connection;
use LdapRecord\Container;
class AppServiceProvider extends ServiceProvider
{
@@ -142,28 +140,7 @@ class AppServiceProvider extends ServiceProvider
return;
}
$host = Settings::get('ldap_host');
if (! $host) {
return;
}
$config = Config::get('ldap.connections.default', []);
$config['hosts'] = [$host];
$config['port'] = (int) Settings::get('ldap_port', $config['port'] ?? 389);
$config['base_dn'] = Settings::get('ldap_base_dn', $config['base_dn'] ?? '');
$config['username'] = Settings::get('ldap_bind_dn', $config['username'] ?? '');
$config['password'] = Settings::get('ldap_bind_password') ?? $config['password'] ?? '';
$config['use_tls'] = Settings::bool('ldap_use_ssl');
Config::set('ldap.connections.default', $config);
Container::addConnection(new Connection($config), 'default');
// Active Directory's objectClass chain (top/person/organizationalPerson/
// user) and login attribute (sAMAccountName) differ from the
// LLDAP/OpenLDAP schema LldapUser is scoped to — swap in AdUser so a
// directory switch doesn't leave every login matching zero entries.
Config::set('auth.providers.users.model', Settings::ldapUserModelClass());
Settings::applyLdapConnection();
}
/**
+36
View File
@@ -6,8 +6,11 @@ use App\Ldap\AdUser;
use App\Ldap\LldapUser;
use App\Models\Setting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use LdapRecord\Connection;
use LdapRecord\Container;
class Settings
{
@@ -311,6 +314,39 @@ class Settings
return static::isActiveDirectory() ? AdUser::class : LldapUser::class;
}
/**
* Points LdapRecord's default connection and the 'users' auth provider
* at the LDAP server saved in settings (no-op while no host is set, so
* the .env values stand). Runs at boot via AppServiceProvider; public
* so the first-run installer can re-apply it after saving a connection
* in the same request it then logs in with.
*/
public static function applyLdapConnection(): void
{
$host = static::get('ldap_host');
if (! $host) {
return;
}
$config = Config::get('ldap.connections.default', []);
$config['hosts'] = [$host];
$config['port'] = (int) static::get('ldap_port', $config['port'] ?? 389);
$config['base_dn'] = static::get('ldap_base_dn', $config['base_dn'] ?? '');
$config['username'] = static::get('ldap_bind_dn', $config['username'] ?? '');
$config['password'] = static::get('ldap_bind_password') ?? $config['password'] ?? '';
$config['use_tls'] = static::bool('ldap_use_ssl');
Config::set('ldap.connections.default', $config);
Container::addConnection(new Connection($config), 'default');
// Active Directory's objectClass chain (top/person/organizationalPerson/
// user) and login attribute (sAMAccountName) differ from the
// LLDAP/OpenLDAP schema LldapUser is scoped to — swap in AdUser so a
// directory switch doesn't leave every login matching zero entries.
Config::set('auth.providers.users.model', static::ldapUserModelClass());
}
/**
* Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to
* find which LDAP attribute logins are matched against. Defaults to
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Support;
use App\Models\User;
/**
* Whether this install still has to go through the first-run installer
* (Livewire\Setup, reached via the RedirectToSetup middleware).
*
* The installer is unauthenticated by necessity — there is nobody to log in
* as yet — so "required" has to be something no later state can fall back
* into: no account exists AND setup was never completed. The flag alone
* wouldn't do, since installs that predate the installer have no flag but
* do have users; the user check alone wouldn't either, since an admin who
* later deletes every account must not reopen an unauthenticated
* "create an admin" page.
*/
class Setup
{
public static function required(): bool
{
if (Settings::bool('setup_completed')) {
return false;
}
// An install from before the installer existed (or one filled by
// `settings:import`): record that once, so this stays a cached
// settings read instead of a users query on every request.
if (User::query()->exists()) {
static::complete();
return false;
}
return true;
}
public static function complete(): void
{
Settings::set('setup_completed', '1');
}
}
+7
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\EnsureRole;
use App\Http\Middleware\RedirectToSetup;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@@ -30,6 +31,12 @@ return Application::configure(basePath: dirname(__DIR__))
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB);
// First-run installer: nothing else is reachable until an account exists.
// Prepended, not appended — route middleware with a framework priority
// (`auth`, `guest`) gets hoisted above anything appended to the group,
// and would bounce /admin to the login form before this ever ran.
$middleware->web(prepend: [RedirectToSetup::class]);
$middleware->alias([
'role' => EnsureRole::class,
'abilities' => CheckAbilities::class,
+1 -1
View File
@@ -39,7 +39,7 @@ return [
|
*/
'version' => '2.0.0',
'version' => '2.1.0',
/*
|--------------------------------------------------------------------------
+7 -24
View File
@@ -14,7 +14,6 @@ use App\Models\SlaRule;
use App\Models\Status;
use App\Models\Subcategory;
use App\Models\Team;
use App\Models\User;
use App\Models\UserField;
use App\Support\Settings;
use Illuminate\Database\Seeder;
@@ -45,7 +44,6 @@ class DatabaseSeeder extends Seeder
$this->seedResponseTemplates();
$this->seedEmailTemplatesAndNotifications();
$this->seedSettings();
$this->seedAdminUser();
}
protected function seedRoles(): void
@@ -409,13 +407,14 @@ class DatabaseSeeder extends Seeder
'restrict_user_creation_to_ldap' => '0',
'restrict_tickets_to_ldap' => '1',
// LDAP — example connection details, not production credentials.
'ldap_enabled' => '1',
'ldap_host' => 'ldap.example.com',
// LDAP — left unconfigured: the first-run installer (Livewire\Setup)
// asks for the real server. Example values here used to make every
// login wait on a lookup against a host that doesn't exist.
'ldap_enabled' => '0',
'ldap_host' => '',
'ldap_port' => '389',
'ldap_base_dn' => 'dc=example,dc=com',
'ldap_bind_dn' => 'cn=admin,dc=example,dc=com',
'ldap_bind_password' => 'changeme-ldap-password',
'ldap_base_dn' => '',
'ldap_bind_dn' => '',
'ldap_use_ssl' => '0',
'ldap_user_filter' => '(uid={0})',
'ldap_auto_provision_guests' => '1',
@@ -435,20 +434,4 @@ class DatabaseSeeder extends Seeder
Settings::set($key, $value);
}
}
/**
* The only account this seeder creates: a local (non-LDAP) admin login
* for initial/emergency access. The login form's LDAP lookup falls back
* to matching by e-mail + local password (see Livewire\Auth\Login), so
* "admin" doubles as both username and e-mail here.
*/
protected function seedAdminUser(): void
{
User::query()->create([
'name' => 'Administrator',
'email' => 'admin@example.com',
'password' => 'admin',
'roles' => ['admin'],
]);
}
}
+5 -4
View File
@@ -206,10 +206,11 @@ after login.
`config/auth.php` defines the default `web` guard against an LDAP-backed user
provider (LdapRecord); a plain Eloquent provider is kept alongside it only for
local tooling/tests that don't hit a directory. In production, LDAP bind is the
primary path; the local fallback account (`admin@example.com` from the seeder)
authenticates against a local password when the LDAP bind doesn't match — this is
the account used for first login after a fresh install (see
[install.md](install.md)).
primary path; a local account (one with its own password)
authenticates against that password when the LDAP bind doesn't match. Nothing is
seeded: a fresh install has no account, so `RedirectToSetup` sends every web
request to the first-run installer (`Livewire\Setup`) until `App\Support\Setup::required()`
turns false — see [install.md](install.md).
`app/Ldap/Handlers/` hooks into LdapRecord's import/sync events:
`AssignDefaultRole` grants the `client` role to new LDAP-provisioned accounts,
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.1.0] - 2026-10-01
### Added
- **First-run installer** (`/setup`): a fresh install has no account, so every
page now leads to an installer that either connects LDAP/AD (with a
connection test) and makes the person signing in the administrator, creates
a local administrator, or imports a settings file. Closes for good once the
first account exists.
### Changed
- The seeder no longer creates `admin@example.com` / `admin`, and seeds LDAP
empty and disabled instead of pointing it at `ldap.example.com`.
- The failed-login message is a full-width alert instead of a small tag.
## [2.0.0] - 2026-10-01
### Changed (breaking — repository layout)
+9 -4
View File
@@ -22,10 +22,15 @@ credential-equivalent.
## Notable design points relevant to security review
- **Auth**: LDAP bind is the primary login path (`config/auth.php`,
`directorytree/ldaprecord-laravel`); a local fallback account
(`admin@example.com`, seeded — **must** have its password changed after
install, see [install.md](install.md)) exists for when LDAP is unavailable or
misconfigured.
`directorytree/ldaprecord-laravel`); local accounts with their own
password (e.g. an administrator created in the first-run installer, see
[install.md](install.md)) keep working when LDAP is unavailable or
misconfigured. No account is seeded.
- **First-run installer** (`/setup`): unauthenticated by necessity, and able to
create an administrator or replace the whole configuration — it is only
reachable while the install has no account *and* has never completed setup
(`App\Support\Setup`), and every installer action re-checks that. Complete it
immediately after deploying; until then anyone who can reach the URL can.
- **Settings override**: Admin > Konfiguracja values in the `settings` table
override `.env` for LDAP/SMTP/session/timezone config at runtime (see
[ARCHITECTURE.md](ARCHITECTURE.md) — "Settings override"). This means a
+46 -26
View File
@@ -258,9 +258,29 @@ docker compose exec app php artisan storage:link
`migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i
wypełni dane referencyjne: kategorie/podkategorie, pola dodatkowe, statusy,
priorytety/SLA, zespoły, szybkie akcje, szablony odpowiedzi/e-mail, branding oraz
**jedno konto lokalne `admin@example.com` / `admin`** — zmień jego hasło od razu
po pierwszym zalogowaniu (Admin > Użytkownicy).
priorytety/SLA, zespoły, szybkie akcje, szablony odpowiedzi/e-mail i branding.
**Nie tworzy żadnego konta** — pierwsze powstaje w instalatorze (niżej).
#### Instalator przy pierwszym uruchomieniu
Dopóki w bazie nie ma żadnego konta, każda strona aplikacji przekierowuje na
`/setup`. Instalator daje trzy drogi:
- **LDAP / Active Directory** — podajesz dane serwera (jest przycisk „Testuj
połączenie”), a potem logujesz się swoim kontem z katalogu. To konto dostaje
role administratora, operatora i klienta; pozostali użytkownicy katalogu
mogą logować się od razu (jako klienci).
- **Konto lokalne** — zakładasz administratora z własnym hasłem, bez LDAP.
LDAP można włączyć później w Admin > Integracje; konto lokalne zostaje jako
dostęp awaryjny.
- **Import ustawień** — wgrywasz plik z innej instalacji (patrz „Przenoszenie
konfiguracji” niżej); logujesz się potem kontami z tamtej instalacji.
Instalator jest dostępny **bez logowania** — bo nie ma jeszcze kim się
zalogować — więc przejdź go od razu po uruchomieniu aplikacji, zanim adres
będzie osiągalny dla kogokolwiek innego. Zamyka się na stałe, gdy tylko
powstanie pierwsze konto (także po późniejszym usunięciu wszystkich kont).
Na serwerze bez przeglądarki to samo da `php artisan settings:import <plik>`.
### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind)
@@ -269,8 +289,8 @@ przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji:
```bash
cd /ścieżka/do/repo
docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm ci
docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
docker run --rm -v "$(pwd)":/app -w /app node:22 npm ci
docker run --rm -v "$(pwd)":/app -w /app node:22 npm run build
```
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
@@ -324,17 +344,17 @@ Admin > Integracje pole **host LDAP** albo w Admin > Poczta **SMTP włączony +
host** jest ustawione, **te wartości wygrywają z `.env`**, bez potrzeby
restartu czy redeployu.
Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery**
(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że
zaraz po instalacji LDAP/SMTP **faktycznie próbują łączyć się z tymi fałszywymi
adresami**, nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system do
użytku:
LDAP po świeżym `migrate --seed` jest pusty i wyłączony — ustawia go
instalator. **SMTP natomiast zawiera przykładowe placeholdery**
(`smtp.example.com`, `changeme-*-password`) — to znaczy, że zaraz po
instalacji poczta **faktycznie próbuje łączyć się z tym fałszywym adresem**,
nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system do użytku:
1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`.
2. Wejdź w **Admin > Integracje** i wpisz prawdziwe dane LDAP (wybierz też
właściwy **Typ katalogu** — LLDAP/OpenLDAP albo Active Directory — jeśli
katalog to nie LLDAP; albo wyczyść pole hosta LDAP, żeby wrócić do
wartości z `.env`), a w **Admin > Poczta** dane SMTP.
1. Zaloguj się kontem utworzonym w instalatorze.
2. W **Admin > Poczta** wpisz prawdziwe dane SMTP. LDAP, jeśli nie był
ustawiony w instalatorze, konfigurujesz w **Admin > Integracje** (wybierz
właściwy **Typ katalogu** — LLDAP/OpenLDAP albo Active Directory; puste
pole hosta LDAP oznacza powrót do wartości z `.env`).
3. Użyj przycisku **„Testuj połączenie”** w Integracje i **„Wyślij testową
wiadomość”** w Poczta, zanim zaczniesz polegać na logowaniu przez katalog
albo na powiadomieniach e-mail.
@@ -375,15 +395,15 @@ Na starej instalacji: Admin > Eksport / import > „Pobierz plik ustawień”, a
docker compose exec app php artisan settings:export # -> storage/app/servicedesk-ustawienia-<data>.json
```
Na nowej, po kroku 1.4 (`migrate --seed`): zaloguj się kontem z seedera i
wgraj plik w Admin > Eksport / import, albo:
Na nowej, po kroku 1.4 (`migrate --seed`): wybierz „Import ustawień” w
instalatorze (`/setup`) i wgraj plik, albo:
```bash
docker compose exec app php artisan settings:import storage/app/servicedesk-ustawienia-<data>.json
```
- Import **zastępuje** konfigurację nowej instalacji (łącznie z kontem
`admin@example.com` z seedera) — po imporcie logujesz się kontami ze starej.
- Import **zastępuje** konfigurację nowej instalacji — po imporcie logujesz
się kontami ze starej.
- Import działa tylko w instalacji **bez zgłoszeń**; zgłoszenia dodaje się
dopiero potem (np. importem z Heska poniżej, który wymaga, żeby konta
użytkowników już istniały).
@@ -558,7 +578,7 @@ Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje
(patrz 1.6 wyżej, w tym konfigurowalne interwały w Admin > Konfiguracja):
```cron
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
* * * * * cd /var/www/servicedesk && php artisan schedule:run >> /dev/null 2>&1
```
Jak w wersji Docker — kolejka (`php artisan queue:work`) nie jest obowiązkowa,
@@ -577,7 +597,7 @@ After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/servicedesk/src
WorkingDirectory=/var/www/servicedesk
ExecStart=/usr/bin/php artisan reverb:start --host=0.0.0.0 --port=8080
Restart=always
@@ -597,15 +617,15 @@ jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
### 2.7. Pierwsze logowanie i dalsza konfiguracja
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
hasło, uzupełnij prawdziwe LDAP w Admin > Integracje i SMTP w Admin > Poczta
(placeholdery z seeda inaczej realnie próbują łączyć się z fałszywymi
adresami), przetestuj oba połączenia.
Identycznie jak w wersji Docker (krok 1.4) — otwórz aplikację, przejdź
instalator (`/setup`: LDAP, konto lokalne albo import ustawień), a potem
uzupełnij prawdziwe SMTP w Admin > Poczta (placeholder z seeda inaczej realnie
próbuje łączyć się z fałszywym adresem) i przetestuj połączenia.
### 2.8. Aktualizacje (bez przestoju)
```bash
cd /var/www/servicedesk/src
cd /var/www/servicedesk
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate --force
+9
View File
@@ -158,6 +158,15 @@ body {
padding: 3px 10px;
border-radius: 6px;
}
/* A full-width message box (e.g. a failed login) — .tag is sized for a
one-word badge and reads cramped when it holds a sentence. */
.alert {
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.45;
}
.alert-danger { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: var(--color-danger); }
.tag-accent { background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); }
.tag-accent-2 { background: color-mix(in srgb, var(--color-accent-2) 20%, transparent); color: var(--color-accent-2); }
.tag-neutral { background: color-mix(in srgb, var(--color-text) 12%, transparent); color: color-mix(in srgb, var(--color-text) 70%, transparent); }
@@ -12,7 +12,7 @@
@endif
@if ($error)
<div class="tag" style="background:color-mix(in srgb, var(--color-danger) 18%, transparent);color:var(--color-danger)">{{ $error }}</div>
<div class="alert alert-danger" role="alert">{{ $error }}</div>
@endif
<div class="field">
+112
View File
@@ -0,0 +1,112 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:flex-start;padding:40px 20px">
<div class="card elev-md" style="width:100%;max-width:560px;padding:28px;gap:16px">
<div>
<h2 style="margin:0 0 6px">Pierwsze uruchomienie</h2>
<p class="text-muted" style="font-size:13px;margin:0">
Ta instalacja nie ma jeszcze żadnego konta. Wybierz, jak ma powstać pierwszy administrator —
resztę konfiguracji dokończysz po zalogowaniu, w panelu Admina.
</p>
</div>
<div class="seg" style="align-self:flex-start">
@foreach (['ldap' => 'LDAP / Active Directory', 'local' => 'Konto lokalne', 'import' => 'Import ustawień'] as $key => $label)
<label class="seg-opt">
<input type="radio" name="setupMode" @checked($mode === $key) wire:click="setMode('{{ $key }}')">
{{ $label }}
</label>
@endforeach
</div>
@if ($error)
<div class="alert alert-danger" role="alert">{{ $error }}</div>
@endif
{{-- ================= LDAP ================= --}}
@if ($mode === 'ldap')
<form wire:submit="finishWithLdap" style="display:flex;flex-direction:column;gap:14px">
<p class="text-muted" style="font-size:12.5px;margin:0">
Podaj dane serwera katalogowego, a potem zaloguj się swoim kontem z katalogu — to konto zostanie
administratorem. Pozostali użytkownicy będą mogli logować się od razu, jako klienci.
</p>
<div class="field">
<label>Typ katalogu</label>
<select class="input" wire:model.live="ldap.directoryType">
<option value="lldap">LLDAP / OpenLDAP</option>
<option value="ad">Active Directory</option>
</select>
</div>
<div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldap.host"></div>
@error('ldap.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div style="display:flex;gap:12px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldap.port"></div>
<div style="flex:none;padding-top:26px"><label class="radio"><input type="checkbox" wire:model="ldap.useSsl" style="position:static;opacity:1;width:auto;height:auto">LDAPS (SSL)</label></div>
</div>
@error('ldap.port') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldap.baseDn"></div>
@error('ldap.baseDn') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Bind DN (konto do odczytu katalogu)</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldap.bindDn"></div>
@error('ldap.bindDn') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Hasło Bind</label><input class="input" type="password" autocomplete="off" wire:model="ldap.bindPassword"></div>
@error('ldap.bindPassword') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field">
<label>Filtr użytkownika (opcjonalnie)</label>
<input class="input" placeholder="{{ $ldap['directoryType'] === 'ad' ? '(sAMAccountName={0})' : '(uid={0})' }}" wire:model="ldap.userFilter">
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testLdap" wire:loading.attr="disabled" wire:target="testLdap">Testuj połączenie</button>
@if ($ldapTestResult === 'ok')
<span style="color:var(--color-success);font-size:12.5px">Połączenie działa.</span>
@elseif ($ldapTestResult === 'error')
<span style="color:var(--color-danger);font-size:12.5px">Brak połączenia: {{ $ldapTestMessage }}</span>
@endif
</div>
<div class="hr"></div>
<div class="field"><label>Twoja nazwa użytkownika w katalogu</label><input class="input" autocomplete="username" wire:model="ldap.username"></div>
@error('ldap.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Twoje hasło</label><input class="input" type="password" autocomplete="current-password" wire:model="ldap.password"></div>
@error('ldap.password') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<button type="submit" class="btn btn-primary btn-block" wire:loading.attr="disabled" wire:target="finishWithLdap">Zapisz i zaloguj jako administrator</button>
</form>
@endif
{{-- ================= LOCAL ADMIN ================= --}}
@if ($mode === 'local')
<form wire:submit="finishWithLocalAdmin" style="display:flex;flex-direction:column;gap:14px">
<p class="text-muted" style="font-size:12.5px;margin:0">
Tworzy lokalne konto administratora z własnym hasłem, niezależne od LDAP. LDAP możesz włączyć
później w Admin &gt; Konfiguracja; to konto zostanie jako dostęp awaryjny.
</p>
<div class="field"><label>Imię i nazwisko</label><input class="input" autocomplete="name" wire:model="local.name"></div>
@error('local.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>E-mail (będzie loginem)</label><input class="input" type="email" autocomplete="username" wire:model="local.email"></div>
@error('local.email') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Hasło (min. 8 znaków)</label><input class="input" type="password" autocomplete="new-password" wire:model="local.password"></div>
@error('local.password') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Powtórz hasło</label><input class="input" type="password" autocomplete="new-password" wire:model="local.password_confirmation"></div>
<button type="submit" class="btn btn-primary btn-block" wire:loading.attr="disabled" wire:target="finishWithLocalAdmin">Utwórz administratora</button>
</form>
@endif
{{-- ================= IMPORT ================= --}}
@if ($mode === 'import')
<form wire:submit="import" style="display:flex;flex-direction:column;gap:14px">
<p class="text-muted" style="font-size:12.5px;margin:0">
Wgraj plik pobrany z innej instalacji (Admin &gt; Eksport / import). Przeniesie całą konfigurację
razem z kontami użytkowników; po imporcie zalogujesz się tak, jak w tamtej instalacji.
</p>
<input type="file" accept=".json,application/json" wire:model="importFile">
@error('importFile') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<button type="submit" class="btn btn-primary btn-block" @disabled(! $importFile) wire:loading.attr="disabled" wire:target="import,importFile">Importuj ustawienia</button>
</form>
@endif
</div>
</div>
</div>
+2
View File
@@ -12,6 +12,7 @@ use App\Livewire\Operator\Queue as OperatorQueue;
use App\Livewire\Operator\Stats as OperatorStats;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Livewire\Settings\NotificationPreferences;
use App\Livewire\Setup;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
@@ -38,6 +39,7 @@ Route::get('/manifest.webmanifest', function () {
])->header('Content-Type', 'application/manifest+json');
})->name('manifest');
Route::get('/setup', Setup::class)->name('setup');
Route::get('/login', Login::class)->middleware('guest')->name('login');
Route::post('/logout', function () {
Auth::logout();
@@ -11,7 +11,7 @@ use Livewire\Livewire;
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
$admin = User::query()->create(['name' => 'Administrator', 'email' => 'admin@example.com', 'roles' => ['admin']]);
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
Livewire::actingAs($admin)->test(Panel::class)
@@ -29,7 +29,7 @@ test('admin can toggle a notification on/off but cannot add, delete, or reassign
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
$admin = User::query()->create(['name' => 'Administrator', 'email' => 'admin@example.com', 'roles' => ['admin']]);
// Repoint ticket_created's NotificationSetting at a template we control,
// instead of the real seeded one, so this test's assertions are its own.
@@ -152,6 +152,7 @@ test('every operator/admin whose new_ticket preference puts a routed ticket in s
$memberA = User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']]);
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
$team->members()->attach([$memberA->id, $memberB->id]);
$admin = User::query()->create(['name' => 'Administrator', 'email' => 'admin@example.com', 'roles' => ['admin']]);
// auto_assign_by_category is on by default (seeded), so the ticket's
// team_id actually becomes the VPN team's id — that's what now drives
@@ -166,11 +167,11 @@ test('every operator/admin whose new_ticket preference puts a routed ticket in s
Notification::assertSentTo($memberA, TicketNotification::class);
Notification::assertSentTo($memberB, TicketNotification::class);
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
// The admin also qualifies: Ticket::isVisibleToOperator() returns
// true unconditionally for admins, and scope_all is the default — this
// is intentional, it's what keeps the one real admin account notified
// about every new ticket without any setup.
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), TicketNotification::class);
Notification::assertSentTo($admin, TicketNotification::class);
// Plus one more: TicketService::create() also fires the pre-existing
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
// since this ticket has no real customer account.
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Livewire\Setup;
use App\Models\User;
use App\Services\ConfigurationTransfer;
use App\Support\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use LdapRecord\Laravel\Testing\DirectoryEmulator;
use Livewire\Livewire;
beforeEach(function () {
// TestCase marks every test's install as set up; these start from a fresh one.
Settings::set('setup_completed', '0');
});
afterEach(function () {
DirectoryEmulator::tearDown();
});
test('a fresh install sends every page to the installer', function () {
$this->get('/')->assertRedirect(route('setup'));
$this->get('/login')->assertRedirect(route('setup'));
$this->get('/admin')->assertRedirect(route('setup'));
$this->get('/setup')->assertOk()->assertSee('Pierwsze uruchomienie');
});
test('an install that already has an account never shows the installer, even without the flag', function () {
User::query()->create(['name' => 'Ktoś', 'email' => 'ktos@example.com', 'roles' => ['client']]);
$this->get('/login')->assertOk();
$this->get('/setup')->assertRedirect(route('login'));
expect(Settings::bool('setup_completed'))->toBeTrue();
});
test('deleting every account later does not reopen the installer', function () {
$user = User::query()->create(['name' => 'Ktoś', 'email' => 'ktos@example.com', 'roles' => ['admin']]);
$this->get('/login')->assertOk();
$user->delete();
$this->get('/login')->assertOk();
$this->get('/setup')->assertRedirect(route('login'));
});
test('an installer page left open stops working once setup has been completed elsewhere', function () {
// One page per action: a component that has answered 404 can't be called again.
$stalePages = collect(['finishWithLocalAdmin', 'finishWithLdap', 'testLdap', 'import'])->mapWithKeys(fn ($action) => [
$action => Livewire::test(Setup::class)
->set('local', ['name' => 'Intruz', 'email' => 'intruz@example.com', 'password' => 'haslo-intruza', 'password_confirmation' => 'haslo-intruza']),
]);
User::query()->create(['name' => 'Prawdziwy Admin', 'email' => 'admin@firma.pl', 'roles' => ['admin']]);
$stalePages->each(fn ($page, $action) => $page->call($action)->assertNotFound());
expect(User::query()->pluck('email')->all())->toBe(['admin@firma.pl']);
});
test('the installer creates a local administrator and signs them in', function () {
Livewire::test(Setup::class)
->call('setMode', 'local')
->set('local', ['name' => 'Anna Admin', 'email' => 'anna@firma.pl', 'password' => 'bardzo-tajne-haslo', 'password_confirmation' => 'bardzo-tajne-haslo'])
->call('finishWithLocalAdmin')
->assertRedirect(route('admin.panel'));
$user = User::query()->where('email', 'anna@firma.pl')->firstOrFail();
expect($user->roles)->toEqualCanonicalizing(['client', 'operator', 'admin'])
->and(Hash::check('bardzo-tajne-haslo', $user->password))->toBeTrue()
->and(Auth::id())->toBe($user->id)
->and(Settings::bool('setup_completed'))->toBeTrue();
$this->get('/setup')->assertRedirect();
});
test('a weak or mismatched local password is rejected and nothing is created', function () {
Livewire::test(Setup::class)
->set('local', ['name' => 'Anna', 'email' => 'anna@firma.pl', 'password' => 'krotkie', 'password_confirmation' => 'inne'])
->call('finishWithLocalAdmin')
->assertHasErrors('local.password');
expect(User::query()->count())->toBe(0)
->and(Settings::bool('setup_completed'))->toBeFalse();
});
test('ldap setup stays open when the server cannot be reached', function () {
Livewire::test(Setup::class)
->set('ldap', ['directoryType' => 'lldap', 'host' => '127.0.0.1', 'port' => '1', 'useSsl' => false, 'baseDn' => 'dc=firma,dc=pl', 'bindDn' => 'cn=admin,dc=firma,dc=pl', 'bindPassword' => 'x', 'userFilter' => '', 'username' => 'anna', 'password' => 'x'])
->call('finishWithLdap')
->assertSee('Nie udało się połączyć z serwerem LDAP');
expect(User::query()->count())->toBe(0)
->and(Settings::bool('setup_completed'))->toBeFalse()
->and(Settings::get('ldap_host'))->not->toBe('127.0.0.1');
});
test('the installer imports a settings file and hands over to the imported accounts', function () {
User::query()->create(['name' => 'Źródłowy Admin', 'email' => 'zrodlo@example.com', 'password' => 'haslo-zrodlowe', 'roles' => ['admin']]);
Settings::set('company_name', 'Firma Źródłowa');
$json = json_encode(app(ConfigurationTransfer::class)->export());
User::query()->delete();
Settings::set('company_name', 'Świeża instalacja');
Settings::set('setup_completed', '0');
Livewire::test(Setup::class)
->call('setMode', 'import')
->set('importFile', UploadedFile::fake()->createWithContent('ustawienia.json', $json))
->call('import')
->assertRedirect(route('login'));
expect(User::query()->pluck('email')->all())->toBe(['zrodlo@example.com'])
->and(Settings::get('company_name'))->toBe('Firma Źródłowa')
->and(Settings::bool('setup_completed'))->toBeTrue();
});
+4 -4
View File
@@ -8,13 +8,13 @@ use Livewire\Livewire;
test('every page renders without error against fully seeded data', function () {
$this->seed();
// DatabaseSeeder deliberately only seeds one admin fallback account and
// no ticket data (see README) — this test needs a client and an operator
// plus a ticket to exercise every page, so it creates its own on top of
// DatabaseSeeder deliberately seeds no accounts (the first-run installer
// creates the first one) and no ticket data (see README) — this test needs
// a client, an operator and an admin plus a ticket to exercise every page, so it creates its own on top of
// the seeded reference data (categories/teams/statuses/priorities/etc).
$client = User::query()->create(['name' => 'Smoke Client', 'email' => 'smoke-client@example.com', 'roles' => ['client']]);
$operator = User::query()->create(['name' => 'Smoke Operator', 'email' => 'smoke-operator@example.com', 'roles' => ['operator']]);
$admin = User::query()->withRole('admin')->firstOrFail();
$admin = User::query()->create(['name' => 'Administrator', 'email' => 'smoke-admin@example.com', 'roles' => ['admin']]);
$clientTicket = Ticket::query()->create([
'number' => '9001',
'customer_id' => $client->id,
+6
View File
@@ -25,5 +25,11 @@ abstract class TestCase extends BaseTestCase
foreach (['client' => 'Klient', 'operator' => 'Operator', 'admin' => 'Administrator'] as $key => $label) {
Role::query()->firstOrCreate(['key' => $key], ['label' => $label]);
}
// Tests exercise an install that is already set up; without this,
// any test that requests a page before creating a user would be
// redirected to the first-run installer (see App\Support\Setup).
// SetupTest turns it back off to test the installer itself.
Settings::set('setup_completed', '1');
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ górnego paska.
katalogu (poza standardowym sync-on-login).
- Konta lokalne (utworzone tu, z hasłem) logują się przez fallback e-mail +
hasło lokalne, gdy dopasowanie po LDAP-owym atrybucie loginu się nie powiedzie
— tak działa domyślne konto awaryjne `admin@example.com`.
— tak działa np. lokalne konto administratora utworzone w instalatorze.
## Zespoły