v2.1.0
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:
1 parent
efff0ddfa0
commit
0de22dbce2
24 files changed
+727
-97
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -39,7 +39,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => '2.0.0',
|
||||
'version' => '2.1.0',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 > 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 > 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user