auth tests
This commit is contained in:
21
.env.example
21
.env.example
@@ -30,21 +30,18 @@ LOG_LEVEL=debug # warning/error in produzione
|
|||||||
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
|
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=db # nome del servizio docker
|
DB_HOST=db # nome del servizio docker
|
||||||
DB_PORT=3306
|
DB_PORT=3306 # porta di connessione INTERNA (mysql nel container)
|
||||||
|
DB_HOST_PORT=3310 # porta pubblicata sull'host; ≠ 3306 per non collidere
|
||||||
DB_DATABASE=dyncoll
|
DB_DATABASE=dyncoll
|
||||||
DB_USERNAME=dyncoll
|
DB_USERNAME=dyncoll
|
||||||
DB_PASSWORD= # segreto: compilare
|
DB_PASSWORD= # segreto: compilare
|
||||||
DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root)
|
DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root)
|
||||||
|
|
||||||
# --- Database legacy (v1) — sorgente per l'ETL `php artisan v1:import` --------
|
# NB: le variabili DB_LEGACY_* (sorgente in SOLA LETTURA per l'ETL
|
||||||
# Connessione in SOLA LETTURA verso il MySQL di dyncoll.v1.
|
# `php artisan v1:import`) NON stanno qui di proposito: servono solo durante la
|
||||||
# Attiva solo durante l'import; richiede rete docker condivisa o tunnel.
|
# migrazione una-tantum da dyncoll.v1, non nella configurazione standard. Al
|
||||||
DB_LEGACY_CONNECTION=mysql
|
# momento del cutover aggiungerle a mano al .env con i valori corretti
|
||||||
DB_LEGACY_HOST=dyncoll_v1_db # container/host del DB v1
|
# dell'ambiente (host/porta/credenziali possono differire dal locale).
|
||||||
DB_LEGACY_PORT=3306
|
|
||||||
DB_LEGACY_DATABASE=lund
|
|
||||||
DB_LEGACY_USERNAME=readonly
|
|
||||||
DB_LEGACY_PASSWORD= # segreto: compilare
|
|
||||||
|
|
||||||
# --- Redis (cache, sessioni, code, Horizon) ----------------------------------
|
# --- Redis (cache, sessioni, code, Horizon) ----------------------------------
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
@@ -62,6 +59,10 @@ FILESYSTEM_DISK=local
|
|||||||
# --- Autenticazione (Sanctum / Fortify) --------------------------------------
|
# --- Autenticazione (Sanctum / Fortify) --------------------------------------
|
||||||
SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080
|
SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080
|
||||||
SESSION_DOMAIN=.dyncoll-dev.local
|
SESSION_DOMAIN=.dyncoll-dev.local
|
||||||
|
# Sicurezza del cookie di sessione (app servita in HTTPS via Traefik).
|
||||||
|
SESSION_SECURE_COOKIE=true
|
||||||
|
SESSION_SAME_SITE=lax
|
||||||
|
SESSION_HTTP_ONLY=true
|
||||||
|
|
||||||
# --- Mail (SMTP) -------------------------------------------------------------
|
# --- Mail (SMTP) -------------------------------------------------------------
|
||||||
# DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025
|
# DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025
|
||||||
|
|||||||
21
Makefile
21
Makefile
@@ -12,7 +12,7 @@ DC := docker compose
|
|||||||
EXEC := $(DC) exec -u $(UID):$(GID) backend
|
EXEC := $(DC) exec -u $(UID):$(GID) backend
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker fe-install fe-add fe-rebuild fe-dev fe-build permissions
|
.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker test fe-install fe-add fe-rebuild fe-dev fe-build fe-lint-css fe-lint-css-fix permissions
|
||||||
|
|
||||||
help: ## Mostra questo aiuto
|
help: ## Mostra questo aiuto
|
||||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
|
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
|
||||||
@@ -42,6 +42,20 @@ import: ## ETL da v1 (container)
|
|||||||
$(EXEC) php artisan v1:import
|
$(EXEC) php artisan v1:import
|
||||||
tinker: ## REPL artisan (container)
|
tinker: ## REPL artisan (container)
|
||||||
$(EXEC) php artisan tinker
|
$(EXEC) php artisan tinker
|
||||||
|
test: ## Suite di test sul DB dedicato db-test (es: make test c="--filter=AuthTest")
|
||||||
|
# Le -e iniettano VERE env nel processo di test → finiscono in $$_SERVER, che
|
||||||
|
# l'env() di Laravel legge per primo. Così vincono sul container (DB_HOST=db,
|
||||||
|
# DB_DATABASE=dyncoll) SENZA dipendere dai force di phpunit.xml. DB_HOST=db-test
|
||||||
|
# = isolamento fisico: la suite non può nemmeno raggiungere il db di sviluppo.
|
||||||
|
$(DC) exec -u $(UID):$(GID) \
|
||||||
|
-e APP_ENV=testing \
|
||||||
|
-e DB_HOST=db-test \
|
||||||
|
-e DB_DATABASE=dyncoll_test \
|
||||||
|
-e CACHE_STORE=array \
|
||||||
|
-e SESSION_DRIVER=array \
|
||||||
|
-e QUEUE_CONNECTION=sync \
|
||||||
|
-e MAIL_MAILER=array \
|
||||||
|
backend php artisan test $(c)
|
||||||
|
|
||||||
## --- Frontend / Node ---
|
## --- Frontend / Node ---
|
||||||
# In dev Vite gira NEL container (make up): HMR via bind-mount, node_modules musl
|
# In dev Vite gira NEL container (make up): HMR via bind-mount, node_modules musl
|
||||||
@@ -58,7 +72,10 @@ fe-dev: ## ALTERNATIVA: vite dev server su host senza Docker (conflitto porta 51
|
|||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
fe-build: ## build di produzione su host (smoke test locale)
|
fe-build: ## build di produzione su host (smoke test locale)
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
fe-lint-css: ## Stylelint (check) nel container — gira anche nel pre-commit
|
||||||
|
$(DC) exec -T frontend npm run lint:css
|
||||||
|
fe-lint-css-fix: ## Stylelint con --fix (container come UID host: scrive sul bind-mount)
|
||||||
|
$(DC) exec -u $(UID):$(GID) -T frontend npm run lint:css:fix
|
||||||
## --- Manutenzione ---
|
## --- Manutenzione ---
|
||||||
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum
|
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum
|
||||||
sudo chgrp -R www-data backend/storage backend/bootstrap/cache
|
sudo chgrp -R www-data backend/storage backend/bootstrap/cache
|
||||||
|
|||||||
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
|
class CreateNewUser implements CreatesNewUsers
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and create a newly registered user.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function create(array $input): User
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class),
|
||||||
|
],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
return User::create([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
trait PasswordValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate passwords.
|
||||||
|
*
|
||||||
|
* @return array<int, Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function passwordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and reset the user's forgotten password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function reset(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||||
|
|
||||||
|
class UpdateUserPassword implements UpdatesUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and update the user's password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'current_password' => ['required', 'string', 'current_password:web'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
], [
|
||||||
|
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||||
|
])->validateWithBag('updatePassword');
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
|
|
||||||
|
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate and update the given user's profile information.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users')->ignore($user->id),
|
||||||
|
],
|
||||||
|
])->validateWithBag('updateProfileInformation');
|
||||||
|
|
||||||
|
if ($input['email'] !== $user->email &&
|
||||||
|
$user instanceof MustVerifyEmail) {
|
||||||
|
$this->updateVerifiedUser($user, $input);
|
||||||
|
} else {
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the given verified user's profile information.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
protected function updateVerifiedUser(User $user, array $input): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'email_verified_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/app/Actions/PurgeUserAction.php
Normal file
82
backend/app/Actions/PurgeUserAction.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Exceptions\CannotDeleteSystemUserException;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellazione definitiva di un utente conforme al GDPR (diritto all'oblio).
|
||||||
|
*
|
||||||
|
* Non rimuove la riga (va preservata l'integrità referenziale verso i contenuti
|
||||||
|
* e la traccia di audit): i contenuti posseduti vengono riassegnati all'utente
|
||||||
|
* di sistema e i dati personali anonimizzati in-place. Il record resta
|
||||||
|
* soft-deleted ma, marcato `anonymized_at`, esce dal cestino e dalle liste.
|
||||||
|
*
|
||||||
|
* Idempotente: un utente già anonimizzato viene ignorato.
|
||||||
|
*/
|
||||||
|
class PurgeUserAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Modelli posseduti (con colonna `user_id`) da riassegnare all'utente di
|
||||||
|
* sistema prima dell'anonimizzazione. Aggiungere qui i modelli di contenuto
|
||||||
|
* man mano che vengono creati (devono usare SoftDeletes).
|
||||||
|
*
|
||||||
|
* @var list<class-string>
|
||||||
|
*/
|
||||||
|
private const OWNED_MODELS = [];
|
||||||
|
|
||||||
|
public function execute(User $user): void
|
||||||
|
{
|
||||||
|
if ($user->is_system) {
|
||||||
|
throw CannotDeleteSystemUserException::make();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->anonymized_at !== null) {
|
||||||
|
return; // già anonimizzato: idempotente
|
||||||
|
}
|
||||||
|
|
||||||
|
$system = User::where('is_system', true)->firstOrFail();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $system) {
|
||||||
|
$this->reassignContent($user, $system);
|
||||||
|
$this->anonymize($user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riassegna all'utente di sistema tutti i contenuti posseduti, inclusi
|
||||||
|
* quelli a loro volta nel cestino.
|
||||||
|
*/
|
||||||
|
private function reassignContent(User $user, User $system): void
|
||||||
|
{
|
||||||
|
foreach (self::OWNED_MODELS as $model) {
|
||||||
|
$model::withTrashed()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->update(['user_id' => $system->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azzera i dati personali mantenendo la riga per l'integrità referenziale.
|
||||||
|
*/
|
||||||
|
private function anonymize(User $user): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => 'Deleted user',
|
||||||
|
'email' => "deleted-{$user->id}@anonymized.invalid",
|
||||||
|
'password' => Hash::make(Str::random(60)),
|
||||||
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
'two_factor_setup_completed_at' => null,
|
||||||
|
'email_verified_at' => null,
|
||||||
|
'remember_token' => null,
|
||||||
|
'must_change_password' => false,
|
||||||
|
'anonymized_at' => now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\PurgeUserAction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonimizza (GDPR) gli utenti soft-deleted oltre la retention, riassegnandone
|
||||||
|
* i contenuti all'utente di sistema. Idempotente, pensato per esecuzione
|
||||||
|
* schedulata giornaliera (vedi routes/console.php).
|
||||||
|
*/
|
||||||
|
class PurgeAnonymizableUsers extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'users:purge
|
||||||
|
{--days=30 : Days kept in the trash before anonymization}
|
||||||
|
{--dry-run : Show the affected users without anonymizing}';
|
||||||
|
|
||||||
|
protected $description = 'Anonymize users deleted beyond the retention window (GDPR right to be forgotten)';
|
||||||
|
|
||||||
|
public function handle(PurgeUserAction $purge): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$users = User::onlyTrashed()
|
||||||
|
->where('is_system', false)
|
||||||
|
->whereNull('anonymized_at')
|
||||||
|
->where('deleted_at', '<=', now()->subDays($days))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($users->isEmpty()) {
|
||||||
|
$this->info('No users to anonymize.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(sprintf(' [dry-run] #%d %s', $user->id, $user->email));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purge->execute($user);
|
||||||
|
$this->line(sprintf(' ✓ anonymized user #%d', $user->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(($dryRun ? '[dry-run] ' : '').'Processed users: '.$users->count());
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sollevata quando si tenta di anonimizzare/eliminare l'utente di sistema,
|
||||||
|
* che deve restare sempre presente come destinatario dei contenuti riassegnati.
|
||||||
|
*/
|
||||||
|
class CannotDeleteSystemUserException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function make(): self
|
||||||
|
{
|
||||||
|
return new self('The system user cannot be deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/app/Http/Controllers/AuthController.php
Normal file
72
backend/app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utente autenticato corrente (include l'accessor setup_status).
|
||||||
|
* Risposta al primo livello, come da convenzione Sanctum SPA.
|
||||||
|
*/
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout: chiude la sessione stateful e ripulisce i cookie.
|
||||||
|
* Sovrascrive la rotta omonima di Fortify (registrata dopo → ha precedenza).
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->tearDownSession($request);
|
||||||
|
|
||||||
|
return $this->forgetAuthCookies($this->messageResponse('auth.logout_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellazione self-service dell'account: soft-delete + logout.
|
||||||
|
* L'anonimizzazione definitiva (GDPR) la fa l'Admin o la retention.
|
||||||
|
*/
|
||||||
|
public function destroyAccount(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_if($user->is_system, 403);
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
$this->tearDownSession($request);
|
||||||
|
|
||||||
|
return $this->forgetAuthCookies($this->messageResponse('account.deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalida la sessione corrente e rigenera il token CSRF.
|
||||||
|
*/
|
||||||
|
private function tearDownSession(Request $request): void
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
if ($request->hasSession()) {
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca per la cancellazione i cookie di sessione e XSRF lato client.
|
||||||
|
*/
|
||||||
|
private function forgetAuthCookies(JsonResponse $response): JsonResponse
|
||||||
|
{
|
||||||
|
return $response
|
||||||
|
->withCookie(Cookie::forget(config('session.cookie')))
|
||||||
|
->withCookie(Cookie::forget('XSRF-TOKEN'));
|
||||||
|
}
|
||||||
|
}
|
||||||
85
backend/app/Http/Controllers/UserController.php
Normal file
85
backend/app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\IndexUserRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestione utenti riservata agli amministratori (sola lettura per ora:
|
||||||
|
* la creazione/invito e la modifica arriveranno con la gestione account).
|
||||||
|
*
|
||||||
|
* Ogni utente è serializzato col ruolo correlato e l'accessor `setup_status`
|
||||||
|
* (password_required / 2fa_setup_required / complete), utile alla dashboard admin.
|
||||||
|
*/
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco paginato degli utenti, con filtri opzionali (ricerca, ruolo, cestino).
|
||||||
|
*/
|
||||||
|
public function index(IndexUserRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = User::query()
|
||||||
|
->with('role')
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
$this->applyTrashed($query, $request->input('trashed'));
|
||||||
|
$this->applySearch($query, $request->input('search'));
|
||||||
|
|
||||||
|
if ($request->filled('role_id')) {
|
||||||
|
$query->where('role_id', $request->integer('role_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->paginate($request->integer('per_page') ?: 20);
|
||||||
|
|
||||||
|
return $this->paginatedCollectionResponse($users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di un singolo utente (anche cestinato/anonimizzato).
|
||||||
|
*/
|
||||||
|
public function show(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$user->load('role');
|
||||||
|
|
||||||
|
return $this->okResponse($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include i soft-deleted nell'elenco: `with` (tutti) o `only` (solo cestinati).
|
||||||
|
*
|
||||||
|
* @param Builder<User> $query
|
||||||
|
*/
|
||||||
|
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||||
|
{
|
||||||
|
match ($trashed) {
|
||||||
|
'with' => $query->withTrashed(),
|
||||||
|
'only' => $query->onlyTrashed(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra per nome o email (ricerca parziale, case-insensitive).
|
||||||
|
*
|
||||||
|
* @param Builder<User> $query
|
||||||
|
*/
|
||||||
|
private function applySearch(Builder $query, ?string $search): void
|
||||||
|
{
|
||||||
|
if (blank($search)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$term = '%'.$search.'%';
|
||||||
|
|
||||||
|
$query->where(function (Builder $q) use ($term): void {
|
||||||
|
$q->where('name', 'like', $term)
|
||||||
|
->orWhere('email', 'like', $term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/app/Http/Controllers/UserRoleController.php
Normal file
78
backend/app/Http/Controllers/UserRoleController.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreUserRoleRequest;
|
||||||
|
use App\Http\Requests\UpdateUserRoleRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class UserRoleController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco dei ruoli.
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->collectionResponse(UserRole::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un nuovo ruolo.
|
||||||
|
*/
|
||||||
|
public function store(StoreUserRoleRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$role = UserRole::create($request->validated());
|
||||||
|
|
||||||
|
return $this->createdResponse($role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di un ruolo.
|
||||||
|
*/
|
||||||
|
public function show(UserRole $userRole): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna un ruolo.
|
||||||
|
*/
|
||||||
|
public function update(UpdateUserRoleRequest $request, UserRole $userRole): JsonResponse
|
||||||
|
{
|
||||||
|
$userRole->update($request->validated());
|
||||||
|
|
||||||
|
return $this->updatedResponse($userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un ruolo (se non di sistema e non in uso).
|
||||||
|
*/
|
||||||
|
public function destroy(UserRole $userRole): JsonResponse
|
||||||
|
{
|
||||||
|
if ($userRole->isSystemRole()) {
|
||||||
|
return $this->conflictResponse('Cannot delete a system role.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userRole->isInUse()) {
|
||||||
|
return $this->conflictResponse('Cannot delete: role assigned to at least one user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userRole->delete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indica se il ruolo è bloccato per l'eliminazione (di sistema o in uso).
|
||||||
|
*/
|
||||||
|
public function usage(UserRole $userRole): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'in_use' => $userRole->isSystemRole() || $userRole->isInUse(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureSetupComplete
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setupStatus = null;
|
||||||
|
|
||||||
|
if ($user->must_change_password) {
|
||||||
|
$setupStatus = 'password_required';
|
||||||
|
} elseif (is_null($user->two_factor_setup_completed_at)) {
|
||||||
|
$setupStatus = '2fa_setup_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($setupStatus !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'setup_status' => $setupStatus,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForceJsonResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$request->headers->set('Accept', 'application/json');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class IsAdmin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! Auth::check() || Auth::user()->role?->name !== 'Admin') {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validazione dei filtri di query per l'elenco utenti (area admin).
|
||||||
|
* L'autorizzazione è demandata ai middleware di rotta (auth + admin + setup).
|
||||||
|
*/
|
||||||
|
class IndexUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'role_id' => ['nullable', 'integer', 'exists:user_roles,id'],
|
||||||
|
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Http/Requests/StoreUserRoleRequest.php
Normal file
28
backend/app/Http/Requests/StoreUserRoleRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreUserRoleRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255|unique:user_roles,name',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/app/Http/Requests/UpdateUserRoleRequest.php
Normal file
36
backend/app/Http/Requests/UpdateUserRoleRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateUserRoleRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->route('user_role')->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('user_roles', 'name')->ignore($id),
|
||||||
|
],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend/app/Http/Traits/ApiResponse.php
Normal file
130
backend/app/Http/Traits/ApiResponse.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risposte JSON standardizzate per i controller API.
|
||||||
|
*
|
||||||
|
* I messaggi sono presi da config/messages.php. Passando un $prefix si possono
|
||||||
|
* usare messaggi specifici per risorsa (es. "user_roles.created"); senza prefix
|
||||||
|
* si usano le chiavi generiche di primo livello.
|
||||||
|
*/
|
||||||
|
trait ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 200 — Lista paginata: { message, data, meta }.
|
||||||
|
*/
|
||||||
|
protected function paginatedCollectionResponse(LengthAwarePaginator $paginator, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||||
|
'data' => $paginator->items(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Collezione di risorse.
|
||||||
|
*/
|
||||||
|
protected function collectionResponse(mixed $collection, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||||
|
'data' => $collection,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Singola risorsa.
|
||||||
|
*/
|
||||||
|
protected function okResponse(mixed $data, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.show" : 'messages.show'),
|
||||||
|
'data' => $data,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 201 — Risorsa creata.
|
||||||
|
*/
|
||||||
|
protected function createdResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.created" : 'messages.created'),
|
||||||
|
'data' => $model,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Risorsa aggiornata.
|
||||||
|
*/
|
||||||
|
protected function updatedResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.updated" : 'messages.updated'),
|
||||||
|
'data' => $model,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Risorsa eliminata.
|
||||||
|
*/
|
||||||
|
protected function deletedResponse(?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.deleted" : 'messages.deleted'),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Risorsa ripristinata (soft delete restore).
|
||||||
|
*/
|
||||||
|
protected function restoredResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.restored" : 'messages.restored'),
|
||||||
|
'data' => $model,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 409 — Conflitto: operazione bloccata (es. risorsa in uso).
|
||||||
|
*/
|
||||||
|
protected function conflictResponse(string $message): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'in_use' => true,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risposta con solo messaggio — chiave config es. "auth.login_success".
|
||||||
|
*/
|
||||||
|
protected function messageResponse(string $configKey, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config("messages.{$configKey}"),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risposta con messaggio e dati — chiave config es. "auth.login_success".
|
||||||
|
*/
|
||||||
|
protected function dataResponse(mixed $data, string $configKey, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config("messages.{$configKey}"),
|
||||||
|
'data' => $data,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/app/Models/Lists/UserRole.php
Normal file
57
backend/app/Models/Lists/UserRole.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Lists;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
class UserRole extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const ADMIN = 'Admin';
|
||||||
|
|
||||||
|
public const SUPERVISOR = 'Supervisor';
|
||||||
|
|
||||||
|
public const USER = 'User';
|
||||||
|
|
||||||
|
public const GUEST = 'Guest';
|
||||||
|
|
||||||
|
protected $table = 'user_roles';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utenti che hanno questo ruolo.
|
||||||
|
*
|
||||||
|
* @return HasMany<User, $this>
|
||||||
|
*/
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class, 'role_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruolo predefinito di sistema: non eliminabile.
|
||||||
|
*/
|
||||||
|
public function isSystemRole(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->name, [self::ADMIN, self::SUPERVISOR, self::USER, self::GUEST], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il ruolo è assegnato ad almeno un utente.
|
||||||
|
*/
|
||||||
|
public function isInUse(): bool
|
||||||
|
{
|
||||||
|
return $this->users()->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,59 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Models\Lists\UserRole;
|
||||||
use Database\Factories\UserFactory;
|
use App\Notifications\ResetPasswordNotification;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
class User extends Authenticatable implements Auditable, MustVerifyEmail
|
||||||
#[Hidden(['password', 'remember_token'])]
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
|
use \OwenIt\Auditing\Auditable;
|
||||||
|
use TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $appends = ['setup_status'];
|
||||||
|
|
||||||
|
protected $auditExclude = ['password', 'remember_token', 'two_factor_secret'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'role_id',
|
||||||
|
'is_system',
|
||||||
|
'must_change_password',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'two_factor_setup_completed_at',
|
||||||
|
'email_verified_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
@@ -27,6 +66,43 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'role_id' => 'integer',
|
||||||
|
'must_change_password' => 'boolean',
|
||||||
|
'two_factor_setup_completed_at' => 'datetime',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
|
'is_system' => 'boolean',
|
||||||
|
'anonymized_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruolo applicativo dell'utente.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<UserRole, $this>
|
||||||
|
*/
|
||||||
|
public function role(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UserRole::class, 'role_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the password reset notification.
|
||||||
|
*/
|
||||||
|
public function sendPasswordResetNotification($token)
|
||||||
|
{
|
||||||
|
$this->notify(new ResetPasswordNotification($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSetupStatusAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->must_change_password) {
|
||||||
|
return 'password_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_null($this->two_factor_setup_completed_at)) {
|
||||||
|
return '2fa_setup_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ResetPasswordNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(public readonly string $token)
|
||||||
|
{
|
||||||
|
// Il token viene passato al costruttore e reso disponibile come proprietà pubblica
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->line('The introduction to the notification.')
|
||||||
|
->action('Notification Action', url('/'))
|
||||||
|
->line('Thank you for using our application!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/app/Notifications/WelcomeNotification.php
Normal file
53
backend/app/Notifications/WelcomeNotification.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class WelcomeNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->line('The introduction to the notification.')
|
||||||
|
->action('Notification Action', url('/'))
|
||||||
|
->line('Thank you for using our application!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
// Rate limiter delle rotte pubbliche (gruppo `throttle:public` in routes/api.php).
|
||||||
|
// Nessun utente loggato → si limita per IP. Tarare il numero sul traffico reale.
|
||||||
|
RateLimiter::for('public', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/app/Providers/FortifyServiceProvider.php
Normal file
56
backend/app/Providers/FortifyServiceProvider.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
|
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Fortify::createUsersUsing(CreateNewUser::class);
|
||||||
|
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||||
|
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||||
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||||
|
|
||||||
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||||
|
|
||||||
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('two-factor', function (Request $request) {
|
||||||
|
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('passkeys', function (Request $request) {
|
||||||
|
$credentialId = $request->input('credential.id');
|
||||||
|
|
||||||
|
return Limit::perMinute(10)->by(
|
||||||
|
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,46 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureSetupComplete;
|
||||||
|
use App\Http\Middleware\ForceJsonResponse;
|
||||||
|
use App\Http\Middleware\IsAdmin;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
|
api: __DIR__.'/../routes/api.php', // NOSONAR
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
then: function (): void {
|
||||||
|
// Auto-discovery: ogni file in routes/api/ è caricato come gruppo sotto
|
||||||
|
// prefisso `api` e stack `api`. Aggiungere un modello = creare un file lì,
|
||||||
|
// senza toccare questo bootstrap. glob() ordina alfabeticamente → ordine
|
||||||
|
// di registrazione deterministico. (Dopo aver aggiunto un file, se usi la
|
||||||
|
// route cache rilancia `route:cache`, come per qualunque nuova rotta.)
|
||||||
|
foreach (glob(base_path('routes/api/*.php')) as $routeFile) {
|
||||||
|
Route::middleware('api')->prefix('api')->group($routeFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
// per Sanctum
|
||||||
|
$middleware->statefulApi();
|
||||||
|
// Configurazione CORS
|
||||||
|
$middleware->preventRequestForgery(except: ['api/*']);
|
||||||
|
$middleware->alias([
|
||||||
|
'admin' => IsAdmin::class,
|
||||||
|
'setup.complete' => EnsureSetupComplete::class,
|
||||||
|
]);
|
||||||
|
// Tier riusabili nei file di routes/api/ — fonte di verità unica.
|
||||||
|
// (nomi `tier.*` per non confliggere con l'alias `admin`.)
|
||||||
|
$middleware->group('tier.app', ['auth:sanctum', 'verified', 'setup.complete']);
|
||||||
|
$middleware->group('tier.admin', ['auth:sanctum', 'verified', 'admin', 'setup.complete']);
|
||||||
|
$middleware->api(append: [ForceJsonResponse::class]);
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\FortifyServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
|
FortifyServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "laravel/laravel",
|
"name": "laravel/laravel",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework", "v.13.15.0"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
|||||||
204
backend/config/audit.php
Normal file
204
backend/config/audit.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use OwenIt\Auditing\Models\Audit;
|
||||||
|
use OwenIt\Auditing\Resolvers\IpAddressResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UrlResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UserAgentResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UserResolver;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'enabled' => env('AUDITING_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Implementation
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define which Audit model implementation should be used.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'implementation' => Audit::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Morph prefix & Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define the morph prefix and authentication guards for the User resolver.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'user' => [
|
||||||
|
'morph_prefix' => 'user',
|
||||||
|
'guards' => [
|
||||||
|
'web',
|
||||||
|
'api',
|
||||||
|
],
|
||||||
|
'resolver' => UserResolver::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Resolvers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define the IP Address, User Agent and URL resolver implementations.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'resolvers' => [
|
||||||
|
'ip_address' => IpAddressResolver::class,
|
||||||
|
'user_agent' => UserAgentResolver::class,
|
||||||
|
'url' => UrlResolver::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Events
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The Eloquent events that trigger an Audit.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'events' => [
|
||||||
|
'created',
|
||||||
|
'updated',
|
||||||
|
'deleted',
|
||||||
|
'restored',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Strict Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Enable the strict mode when auditing?
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'strict' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global exclude
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Have something you always want to exclude by default? - add it here.
|
||||||
|
| Note that this is overwritten (not merged) with local exclude
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'exclude' => [],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Empty Values
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Should Audit records be stored when the recorded old_values & new_values
|
||||||
|
| are both empty?
|
||||||
|
|
|
||||||
|
| Some events may be empty on purpose. Use allowed_empty_values to exclude
|
||||||
|
| those from the empty values check. For example when auditing
|
||||||
|
| model retrieved events which will never have new and old values.
|
||||||
|
|
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'empty_values' => true,
|
||||||
|
'allowed_empty_values' => [
|
||||||
|
'retrieved',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Allowed Array Values
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Should the array values be audited?
|
||||||
|
|
|
||||||
|
| By default, array values are not allowed. This is to prevent performance
|
||||||
|
| issues when storing large amounts of data. You can override this by
|
||||||
|
| setting allow_array_values to true.
|
||||||
|
*/
|
||||||
|
'allowed_array_values' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Timestamps
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timestamps' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Threshold
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify a threshold for the amount of Audit records a model can have.
|
||||||
|
| Zero means no limit.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'threshold' => 0,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The default audit driver used to keep track of changes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => 'database',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Driver Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Available audit drivers and respective configurations.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'drivers' => [
|
||||||
|
'database' => [
|
||||||
|
'table' => 'audits',
|
||||||
|
'connection' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Queue Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Available audit queue configurations.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'queue' => [
|
||||||
|
'enable' => false,
|
||||||
|
'connection' => 'sync',
|
||||||
|
'queue' => 'default',
|
||||||
|
'delay' => 0,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Console
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Whether console events should be audited (eg. php artisan db:seed).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'console' => false,
|
||||||
|
];
|
||||||
45
backend/config/cors.php
Normal file
45
backend/config/cors.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Le rotte Fortify (login/logout/2FA/reset) sono sotto prefisso `api/`,
|
||||||
|
// quindi `api/*` le copre tutte; `sanctum/csrf-cookie` rilascia il cookie XSRF.
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
|
// Origini autorizzate per le richieste cross-origin con credenziali (cookie).
|
||||||
|
// Con `supports_credentials => true` NON è ammesso il wildcard `*`: serve la
|
||||||
|
// lista esatta delle origini. In dev l'unica che porta il cookie di sessione è
|
||||||
|
// APP_URL, perché SESSION_DOMAIN è scopato a `.dyncoll-dev.local`.
|
||||||
|
// FRONTEND_URL (se valorizzato) fa da override e accetta più origini separate
|
||||||
|
// da virgola; altrimenti si usa APP_URL.
|
||||||
|
'allowed_origins' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string) env('FRONTEND_URL', env('APP_URL', 'https://dyncoll-dev.local')))
|
||||||
|
))),
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => true,
|
||||||
|
|
||||||
|
];
|
||||||
160
backend/config/fortify.php
Normal file
160
backend/config/fortify.php
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Guard
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which authentication guard Fortify will use while
|
||||||
|
| authenticating users. This value should correspond with one of your
|
||||||
|
| guards that is already present in your "auth" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => 'web',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Password Broker
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which password broker Fortify can use when a user
|
||||||
|
| is resetting their password. This configured value should match one
|
||||||
|
| of your password brokers setup in your "auth" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => 'users',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Username / Email
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value defines which model attribute should be considered as your
|
||||||
|
| application's "username" field. Typically, this might be the email
|
||||||
|
| address of the users but you are free to change this value here.
|
||||||
|
|
|
||||||
|
| Out of the box, Fortify expects forgot password and reset password
|
||||||
|
| requests to have a field named 'email'. If the application uses
|
||||||
|
| another name for the field you may define it below as needed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'username' => 'email',
|
||||||
|
|
||||||
|
'email' => 'email',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Lowercase Usernames
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value defines whether usernames should be lowercased before saving
|
||||||
|
| them in the database, as some database system string fields are case
|
||||||
|
| sensitive. You may disable this for your application if necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lowercase_usernames' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Home Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the path where users will get redirected during
|
||||||
|
| authentication or password reset when the operations are successful
|
||||||
|
| and the user is authenticated. You are free to change this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'home' => '/home',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Routes Prefix / Subdomain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which prefix Fortify will assign to all the routes
|
||||||
|
| that it registers with the application. If necessary, you may change
|
||||||
|
| subdomain under which all of the Fortify routes will be available.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => 'api',
|
||||||
|
|
||||||
|
'domain' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Routes Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which middleware Fortify will assign to the routes
|
||||||
|
| that it registers with the application. If necessary, you may change
|
||||||
|
| these middleware but typically this provided default is preferred.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default, Fortify will throttle logins to five requests per minute for
|
||||||
|
| every email and IP address combination. However, if you would like to
|
||||||
|
| specify a custom rate limiter to call then you may specify it here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'limiters' => [
|
||||||
|
'login' => 'login',
|
||||||
|
'two-factor' => 'two-factor',
|
||||||
|
'passkeys' => 'passkeys',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Register View Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify if the routes returning views should be disabled as
|
||||||
|
| you may not need them when building your own application. This may be
|
||||||
|
| especially true if you're writing a custom single-page application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'views' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Features
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some of the Fortify features are optional. You may disable the features
|
||||||
|
| by removing them from this array. You're free to only remove some of
|
||||||
|
| these features or you can even remove all of these if you need to.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'features' => [
|
||||||
|
Features::registration(),
|
||||||
|
Features::resetPasswords(),
|
||||||
|
Features::emailVerification(),
|
||||||
|
Features::updateProfileInformation(),
|
||||||
|
Features::updatePasswords(),
|
||||||
|
Features::twoFactorAuthentication([
|
||||||
|
'confirm' => true,
|
||||||
|
'confirmPassword' => true,
|
||||||
|
// 'window' => 0,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
37
backend/config/messages.php
Normal file
37
backend/config/messages.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Messaggi CRUD generici
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Usati dal trait App\Http\Traits\ApiResponse quando non viene passato un
|
||||||
|
| prefisso specifico per risorsa. Per messaggi dedicati a una risorsa basta
|
||||||
|
| aggiungere un sotto-array (es. 'user_roles' => ['created' => '...']).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'index' => 'List retrieved successfully.',
|
||||||
|
'show' => 'Resource retrieved successfully.',
|
||||||
|
'created' => 'Resource created successfully.',
|
||||||
|
'updated' => 'Resource updated successfully.',
|
||||||
|
'deleted' => 'Resource deleted successfully.',
|
||||||
|
'restored' => 'Resource restored successfully.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Autenticazione / account
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth' => [
|
||||||
|
'logout_success' => 'Logged out successfully.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'account' => [
|
||||||
|
'deleted' => 'Account deleted successfully.',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
102
backend/config/sanctum.php
Normal file
102
backend/config/sanctum.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(
|
||||||
|
',',
|
||||||
|
env(
|
||||||
|
'SANCTUM_STATEFUL_DOMAINS',
|
||||||
|
sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort(),
|
||||||
|
// Sanctum::currentRequestHost(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
'sanctum' => [
|
||||||
|
'driver' => 'sanctum',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
25
backend/database/factories/Lists/UserRoleFactory.php
Normal file
25
backend/database/factories/Lists/UserRoleFactory.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Lists;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserRole>
|
||||||
|
*/
|
||||||
|
class UserRoleFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = UserRole::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ucfirst($this->faker->unique()->word()),
|
||||||
|
'description' => $this->faker->sentence(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_roles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->string('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_roles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('role_id')->nullable()->after('id');
|
||||||
|
$table->foreign('role_id')->references('id')->on('user_roles')->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['role_id']);
|
||||||
|
$table->dropColumn('role_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('must_change_password')->default(true)->after('password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('must_change_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
// Utente di sistema: riceve i contenuti riassegnati e non è eliminabile.
|
||||||
|
$table->boolean('is_system')->default(false)->after('role_id');
|
||||||
|
|
||||||
|
// Traccia l'avvenuta anonimizzazione (GDPR). Il record resta ai fini
|
||||||
|
// di integrità referenziale ma esce dal cestino e dalle liste utenti.
|
||||||
|
$table->timestamp('anonymized_at')->nullable()->after('deleted_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['is_system', 'anonymized_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->text('two_factor_secret')
|
||||||
|
->after('password')
|
||||||
|
->nullable();
|
||||||
|
|
||||||
|
$table->text('two_factor_recovery_codes')
|
||||||
|
->after('two_factor_secret')
|
||||||
|
->nullable();
|
||||||
|
|
||||||
|
$table->timestamp('two_factor_confirmed_at')
|
||||||
|
->after('two_factor_recovery_codes')
|
||||||
|
->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'two_factor_confirmed_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||||
|
$table = config('audit.drivers.database.table', 'audits');
|
||||||
|
|
||||||
|
Schema::connection($connection)->create($table, function (Blueprint $table) {
|
||||||
|
|
||||||
|
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||||
|
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->string($morphPrefix.'_type')->nullable();
|
||||||
|
$table->unsignedBigInteger($morphPrefix.'_id')->nullable();
|
||||||
|
$table->string('event');
|
||||||
|
$table->morphs('auditable');
|
||||||
|
$table->text('old_values')->nullable();
|
||||||
|
$table->text('new_values')->nullable();
|
||||||
|
$table->text('url')->nullable();
|
||||||
|
$table->ipAddress('ip_address')->nullable();
|
||||||
|
$table->string('user_agent', 1023)->nullable();
|
||||||
|
$table->string('tags')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index([$morphPrefix.'_id', $morphPrefix.'_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||||
|
$table = config('audit.drivers.database.table', 'audits');
|
||||||
|
|
||||||
|
Schema::connection($connection)->drop($table);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->timestamp('two_factor_setup_completed_at')
|
||||||
|
->nullable()
|
||||||
|
->after('two_factor_confirmed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('two_factor_setup_completed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
@@ -11,15 +10,14 @@ class DatabaseSeeder extends Seeder
|
|||||||
use WithoutModelEvents;
|
use WithoutModelEvents;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed the application's database.
|
* Seed dell'applicazione. L'ordine conta: i ruoli prima dell'utente di
|
||||||
|
* sistema (che richiede il ruolo Admin).
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
$this->call([
|
||||||
|
UserRoleSeeder::class,
|
||||||
User::factory()->create([
|
SystemUserSeeder::class,
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/database/seeders/SystemUserSeeder.php
Normal file
41
backend/database/seeders/SystemUserSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea l'utente di sistema: riceve i contenuti riassegnati dagli utenti
|
||||||
|
* cancellati (flusso GDPR / oblio) e non è eliminabile né accessibile
|
||||||
|
* (password casuale, nessun login previsto). Idempotente.
|
||||||
|
*
|
||||||
|
* Dipende da UserRoleSeeder (serve il ruolo Admin).
|
||||||
|
*/
|
||||||
|
class SystemUserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public const EMAIL = 'system@dyncoll.local';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$adminRoleId = UserRole::where('name', UserRole::ADMIN)->value('id');
|
||||||
|
|
||||||
|
User::updateOrCreate(
|
||||||
|
['email' => self::EMAIL],
|
||||||
|
[
|
||||||
|
'name' => 'Dyncoll System',
|
||||||
|
'password' => Hash::make(Str::random(60)),
|
||||||
|
'role_id' => $adminRoleId,
|
||||||
|
'is_system' => true,
|
||||||
|
'must_change_password' => false,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'two_factor_setup_completed_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->command?->info('✅ System user ready: '.self::EMAIL);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/database/seeders/UserRoleSeeder.php
Normal file
42
backend/database/seeders/UserRoleSeeder.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class UserRoleSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ruoli applicativi di base. Idempotente.
|
||||||
|
* (description in inglese: lingua ufficiale dell'applicazione.)
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::ADMIN],
|
||||||
|
['description' => 'Full access to all features.'
|
||||||
|
.' Manages users, configurations and global data supervision.'
|
||||||
|
.' Can create, read, update and delete any record.']
|
||||||
|
);
|
||||||
|
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::SUPERVISOR],
|
||||||
|
['description' => 'Limited access to certain features.'
|
||||||
|
.' Can manage users but not administrators.'
|
||||||
|
.' Can create, read, update and delete both own and other users\' records.']
|
||||||
|
);
|
||||||
|
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::USER],
|
||||||
|
['description' => 'Day-to-day operational use. Can access all records'
|
||||||
|
.' and create new ones, but can only update or delete their own.']
|
||||||
|
);
|
||||||
|
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::GUEST],
|
||||||
|
['description' => 'Very limited access. Can only view information'
|
||||||
|
.' not publicly available, with no editing capabilities.']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,19 +18,25 @@
|
|||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<!-- IMPORTANTE: la sicurezza del DB NON dipende da questo file. Nel container
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
le env (DB_HOST=db, DB_DATABASE=dyncoll, APP_ENV=local, ...) sono REALI e
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
finiscono in $_SERVER, che l'env() di Laravel legge PRIMA: il force qui sotto
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
NON le sovrascrive. L'override autoritativo arriva dalle -e di `make test`
|
||||||
<env name="CACHE_STORE" value="array"/>
|
(DB_HOST=db-test, DB_DATABASE=dyncoll_test). Questi force servono solo come
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
rete per i driver non-DB se qualcuno lancia `php artisan test` a mano; il
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
redirect verso db-test resta garantito dal guard in Tests\TestCase. -->
|
||||||
<env name="DB_URL" value=""/>
|
<env name="APP_ENV" value="testing" force="true"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="DB_CONNECTION" value="mysql" force="true"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="DB_DATABASE" value="dyncoll_test" force="true"/>
|
||||||
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
36
backend/routes/api.php
Normal file
36
backend/routes/api.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// File "core": rotte di sessione e pubbliche trasversali. Le rotte per modello
|
||||||
|
// stanno in routes/api/*.php, caricate automaticamente dal then: in bootstrap/app.php
|
||||||
|
// (prefisso `api` + tier riusabili `tier.app` / `tier.admin`).
|
||||||
|
|
||||||
|
$AUTH = 'auth:sanctum';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rotte pubbliche (nessun auth), protette dal throttling.
|
||||||
|
// Limiter `public` definito in App\Providers\AppServiceProvider::boot()
|
||||||
|
// (60 req/min per IP) — modificare lì il limite.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
Route::middleware('throttle:public')->group(function () {
|
||||||
|
// Solo endpoint GET (index/show) leggibili da chiunque.
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Tier "transitional": autenticato, MA setup eventualmente incompleto
|
||||||
|
// (password forzata da cambiare e/o 2FA non ancora configurata).
|
||||||
|
//
|
||||||
|
// ⚠️ NON aggiungere `setup.complete` qui: queste rotte devono restare
|
||||||
|
// raggiungibili PROPRIO durante il setup, altrimenti deadlock. In particolare
|
||||||
|
// il frontend legge GET /user per scoprire `setup_status` e decidere cosa
|
||||||
|
// mostrare. Le azioni di setup vero (cambio password, abilitazione 2FA) e la
|
||||||
|
// verifica email le registra Fortify, già auth-only e fuori da `setup.complete`.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
Route::middleware($AUTH)->group(function () {
|
||||||
|
Route::get('/user', [AuthController::class, 'me']);
|
||||||
|
// Sovrascrive la rotta logout di Fortify (registrata dopo → precedenza).
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
Route::delete('/account', [AuthController::class, 'destroyAccount']);
|
||||||
|
});
|
||||||
18
backend/routes/api/user-roles.php
Normal file
18
backend/routes/api/user-roles.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\UserRoleController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Prefisso `api` e stack `api` sono applicati dal loader in bootstrap/app.php.
|
||||||
|
// I tier `tier.app` / `tier.admin` sono definiti lì come middleware group.
|
||||||
|
|
||||||
|
// Lettura: utente pienamente operativo (verificato + setup completato).
|
||||||
|
Route::middleware('tier.app')->group(function () {
|
||||||
|
Route::apiResource('user-roles', UserRoleController::class)->only(['index', 'show']);
|
||||||
|
Route::get('user-roles/{user_role}/usage', [UserRoleController::class, 'usage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scrittura: solo Admin.
|
||||||
|
Route::middleware('tier.admin')->group(function () {
|
||||||
|
Route::apiResource('user-roles', UserRoleController::class)->only(['store', 'update', 'destroy']);
|
||||||
|
});
|
||||||
11
backend/routes/api/users.php
Normal file
11
backend/routes/api/users.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\UserController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Gestione utenti (sola lettura): area admin.
|
||||||
|
Route::middleware('tier.admin')->group(function () {
|
||||||
|
Route::get('users', [UserController::class, 'index']);
|
||||||
|
// withTrashed: l'admin può ispezionare anche utenti cestinati/anonimizzati.
|
||||||
|
Route::get('users/{user}', [UserController::class, 'show'])->withTrashed();
|
||||||
|
});
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
// Anonimizzazione GDPR degli utenti oltre la retention (richiede lo scheduler attivo).
|
||||||
|
Schedule::command('users:purge')->daily();
|
||||||
|
|||||||
66
backend/tests/Concerns/CreatesUsers.php
Normal file
66
backend/tests/Concerns/CreatesUsers.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper per costruire utenti nei vari stati del setup, senza ripetere ovunque
|
||||||
|
* lo stesso array di attributi. La factory di default crea un utente verificato
|
||||||
|
* ma con must_change_password=true (default di colonna) → setup incompleto: qui
|
||||||
|
* forniamo scorciatoie per gli stati che servono ai test (operativo, admin,
|
||||||
|
* di sistema, setup incompleto, non verificato).
|
||||||
|
*/
|
||||||
|
trait CreatesUsers
|
||||||
|
{
|
||||||
|
/** Ruolo applicativo, riusato se già presente (idempotente in-test). */
|
||||||
|
protected function role(string $name): UserRole
|
||||||
|
{
|
||||||
|
return UserRole::firstOrCreate(['name' => $name], ['description' => $name.' role']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utente pienamente operativo: verificato, setup completo, ruolo User. */
|
||||||
|
protected function operationalUser(array $overrides = []): User
|
||||||
|
{
|
||||||
|
return User::factory()->create(array_merge([
|
||||||
|
'role_id' => $this->role(UserRole::USER)->id,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'must_change_password' => false,
|
||||||
|
'two_factor_setup_completed_at' => now(),
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operativo con ruolo Admin → supera tier.admin / IsAdmin. */
|
||||||
|
protected function adminUser(array $overrides = []): User
|
||||||
|
{
|
||||||
|
return $this->operationalUser(array_merge([
|
||||||
|
'role_id' => $this->role(UserRole::ADMIN)->id,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utente di sistema (destinatario dei contenuti riassegnati, non eliminabile). */
|
||||||
|
protected function systemUser(array $overrides = []): User
|
||||||
|
{
|
||||||
|
return $this->operationalUser(array_merge([
|
||||||
|
'is_system' => true,
|
||||||
|
'role_id' => $this->role(UserRole::ADMIN)->id,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Autenticato ma setup incompleto (deve cambiare password). */
|
||||||
|
protected function setupIncompleteUser(array $overrides = []): User
|
||||||
|
{
|
||||||
|
return $this->operationalUser(array_merge([
|
||||||
|
'must_change_password' => true,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setup completo ma email non verificata → blocco di `verified`. */
|
||||||
|
protected function unverifiedUser(array $overrides = []): User
|
||||||
|
{
|
||||||
|
return $this->operationalUser(array_merge([
|
||||||
|
'email_verified_at' => null,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/tests/Feature/Auth/AccessTierTest.php
Normal file
73
backend/tests/Feature/Auth/AccessTierTest.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica la scala di accesso (tier.app / tier.admin) attraverso rotte reali:
|
||||||
|
* - tier.app → user-roles index (auth + verified + setup.complete)
|
||||||
|
* - tier.admin→ users index (+ admin)
|
||||||
|
* Copre i rami di EnsureSetupComplete, del middleware `verified` e di IsAdmin.
|
||||||
|
*/
|
||||||
|
class AccessTierTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const APP_ROUTE = '/api/user-roles';
|
||||||
|
|
||||||
|
private const ADMIN_ROUTE = '/api/users';
|
||||||
|
|
||||||
|
public function test_tier_app_allows_a_fully_operational_user(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson(self::APP_ROUTE)->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_app_requires_authentication(): void
|
||||||
|
{
|
||||||
|
$this->getJson(self::APP_ROUTE)->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_app_blocks_user_who_must_change_password(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->setupIncompleteUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson(self::APP_ROUTE)
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('setup_status', 'password_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_app_blocks_user_without_two_factor_setup(): void
|
||||||
|
{
|
||||||
|
$user = $this->operationalUser(['two_factor_setup_completed_at' => null]);
|
||||||
|
$this->actingAs($user, 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson(self::APP_ROUTE)
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('setup_status', '2fa_setup_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_app_blocks_unverified_user(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->unverifiedUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson(self::APP_ROUTE)->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_admin_allows_admin(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson(self::ADMIN_ROUTE)->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_admin_forbids_non_admin(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson(self::ADMIN_ROUTE)->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
86
backend/tests/Feature/Auth/AuthControllerTest.php
Normal file
86
backend/tests/Feature/Auth/AuthControllerTest.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_me_returns_authenticated_user_with_setup_status(): void
|
||||||
|
{
|
||||||
|
$user = $this->operationalUser();
|
||||||
|
$this->actingAs($user, 'sanctum');
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/user');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('id', $user->id)
|
||||||
|
->assertJsonPath('email', $user->email)
|
||||||
|
->assertJsonPath('setup_status', 'complete');
|
||||||
|
|
||||||
|
// Gli attributi sensibili non devono mai essere serializzati.
|
||||||
|
$this->assertArrayNotHasKey('password', $response->json());
|
||||||
|
$this->assertArrayNotHasKey('two_factor_secret', $response->json());
|
||||||
|
$this->assertArrayNotHasKey('two_factor_recovery_codes', $response->json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_me_is_reachable_during_setup(): void
|
||||||
|
{
|
||||||
|
// La rotta /user è nel tier "transitional": deve restare accessibile anche
|
||||||
|
// a setup incompleto, così il frontend può leggere setup_status.
|
||||||
|
$user = $this->setupIncompleteUser();
|
||||||
|
$this->actingAs($user, 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson('/api/user')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('setup_status', 'password_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_me_requires_authentication(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/user')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_logout_succeeds_and_forgets_cookies(): void
|
||||||
|
{
|
||||||
|
$user = $this->operationalUser();
|
||||||
|
$this->actingAs($user, 'sanctum');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/logout');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('message', config('messages.auth.logout_success'))
|
||||||
|
->assertCookieExpired('XSRF-TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_destroy_account_soft_deletes_the_user(): void
|
||||||
|
{
|
||||||
|
$user = $this->operationalUser();
|
||||||
|
$this->actingAs($user, 'sanctum');
|
||||||
|
|
||||||
|
$this->deleteJson('/api/account')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('message', config('messages.account.deleted'));
|
||||||
|
|
||||||
|
$this->assertSoftDeleted('users', ['id' => $user->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_destroy_account_is_forbidden_for_the_system_user(): void
|
||||||
|
{
|
||||||
|
$user = $this->systemUser();
|
||||||
|
$this->actingAs($user, 'sanctum');
|
||||||
|
|
||||||
|
$this->deleteJson('/api/account')->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('users', ['id' => $user->id, 'deleted_at' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_session_endpoints_require_authentication(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/api/logout')->assertUnauthorized();
|
||||||
|
$this->deleteJson('/api/account')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/tests/Feature/EnvironmentSafetyTest.php
Normal file
24
backend/tests/Feature/EnvironmentSafetyTest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardia di sicurezza: garantisce che la suite giri sul DB di test dedicato
|
||||||
|
* (servizio db-test), non sul database di sviluppo `dyncoll`. È volutamente
|
||||||
|
* read-only (non usa RefreshDatabase): qui assertiamo che l'host e il nome DB
|
||||||
|
* siano quelli di test. La protezione "dura" — che aborta PRIMA di un eventuale
|
||||||
|
* migrate:fresh — sta nel guard beforeRefreshingDatabase() di Tests\TestCase.
|
||||||
|
*/
|
||||||
|
class EnvironmentSafetyTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_suite_runs_against_the_dedicated_test_database(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('testing', app()->environment());
|
||||||
|
$this->assertSame('mysql', config('database.default'));
|
||||||
|
$this->assertSame('db-test', config('database.connections.mysql.host'));
|
||||||
|
$this->assertSame('dyncoll_test', DB::connection()->getDatabaseName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* A basic test example.
|
|
||||||
*/
|
|
||||||
public function test_the_application_returns_a_successful_response(): void
|
|
||||||
{
|
|
||||||
$response = $this->get('/');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
77
backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php
Normal file
77
backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PurgeAnonymizableUsersCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/** Crea un utente cestinato con deleted_at retrodatato di $days giorni. */
|
||||||
|
private function trashedDaysAgo(int $days): User
|
||||||
|
{
|
||||||
|
$user = $this->operationalUser();
|
||||||
|
$user->delete();
|
||||||
|
User::withTrashed()->whereKey($user->id)->update(['deleted_at' => now()->subDays($days)]);
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_anonymizes_users_past_the_retention_window(): void
|
||||||
|
{
|
||||||
|
$this->systemUser();
|
||||||
|
$old = $this->trashedDaysAgo(40);
|
||||||
|
|
||||||
|
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
|
||||||
|
|
||||||
|
$old->refresh();
|
||||||
|
$this->assertNotNull($old->anonymized_at);
|
||||||
|
$this->assertSame('Deleted user', $old->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_skips_users_still_within_retention(): void
|
||||||
|
{
|
||||||
|
$this->systemUser();
|
||||||
|
$recent = $this->trashedDaysAgo(5);
|
||||||
|
|
||||||
|
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
|
||||||
|
|
||||||
|
$recent->refresh();
|
||||||
|
$this->assertNull($recent->anonymized_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_does_not_modify_anyone(): void
|
||||||
|
{
|
||||||
|
$this->systemUser();
|
||||||
|
$old = $this->trashedDaysAgo(40);
|
||||||
|
|
||||||
|
$this->artisan('users:purge', ['--days' => 30, '--dry-run' => true])->assertSuccessful();
|
||||||
|
|
||||||
|
$old->refresh();
|
||||||
|
$this->assertNull($old->anonymized_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_never_touches_the_system_user(): void
|
||||||
|
{
|
||||||
|
$system = $this->systemUser();
|
||||||
|
// Anche se (per assurdo) cestinato e vecchio, l'utente di sistema è escluso.
|
||||||
|
$system->delete();
|
||||||
|
User::withTrashed()->whereKey($system->id)->update(['deleted_at' => now()->subDays(99)]);
|
||||||
|
|
||||||
|
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
|
||||||
|
|
||||||
|
$this->assertNull($system->fresh()->anonymized_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_when_there_is_nothing_to_do(): void
|
||||||
|
{
|
||||||
|
$this->systemUser();
|
||||||
|
|
||||||
|
$this->artisan('users:purge', ['--days' => 30])
|
||||||
|
->expectsOutputToContain('No users to anonymize.')
|
||||||
|
->assertSuccessful();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/tests/Feature/PurgeUserActionTest.php
Normal file
55
backend/tests/Feature/PurgeUserActionTest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Actions\PurgeUserAction;
|
||||||
|
use App\Exceptions\CannotDeleteSystemUserException;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PurgeUserActionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function purge(): PurgeUserAction
|
||||||
|
{
|
||||||
|
return app(PurgeUserAction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_anonymizes_personal_data_in_place(): void
|
||||||
|
{
|
||||||
|
$this->systemUser();
|
||||||
|
$user = $this->operationalUser(['name' => 'Mario Rossi']);
|
||||||
|
|
||||||
|
$this->purge()->execute($user);
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame('Deleted user', $user->name);
|
||||||
|
$this->assertSame("deleted-{$user->id}@anonymized.invalid", $user->email);
|
||||||
|
$this->assertNotNull($user->anonymized_at);
|
||||||
|
$this->assertNull($user->email_verified_at);
|
||||||
|
$this->assertNull($user->two_factor_setup_completed_at);
|
||||||
|
$this->assertFalse($user->must_change_password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_is_idempotent_on_already_anonymized_users(): void
|
||||||
|
{
|
||||||
|
$this->systemUser();
|
||||||
|
$user = $this->operationalUser(['name' => 'Keep Me', 'anonymized_at' => now()]);
|
||||||
|
|
||||||
|
$this->purge()->execute($user);
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
// Già anonimizzato → uscita anticipata: nessuna riscrittura del nome.
|
||||||
|
$this->assertSame('Keep Me', $user->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_refuses_to_anonymize_the_system_user(): void
|
||||||
|
{
|
||||||
|
$system = $this->systemUser();
|
||||||
|
|
||||||
|
$this->expectException(CannotDeleteSystemUserException::class);
|
||||||
|
|
||||||
|
$this->purge()->execute($system);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/tests/Feature/SeederTest.php
Normal file
73
backend/tests/Feature/SeederTest.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\DatabaseSeeder;
|
||||||
|
use Database\Seeders\SystemUserSeeder;
|
||||||
|
use Database\Seeders\UserRoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SeederTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_user_role_seeder_creates_the_base_roles(): void
|
||||||
|
{
|
||||||
|
$this->seed(UserRoleSeeder::class);
|
||||||
|
|
||||||
|
$this->assertSame(4, UserRole::count());
|
||||||
|
foreach ([UserRole::ADMIN, UserRole::SUPERVISOR, UserRole::USER, UserRole::GUEST] as $name) {
|
||||||
|
$this->assertDatabaseHas('user_roles', ['name' => $name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_role_descriptions_are_in_english(): void
|
||||||
|
{
|
||||||
|
$this->seed(UserRoleSeeder::class);
|
||||||
|
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'Full access',
|
||||||
|
(string) UserRole::where('name', UserRole::ADMIN)->value('description')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_role_seeder_is_idempotent(): void
|
||||||
|
{
|
||||||
|
$this->seed(UserRoleSeeder::class);
|
||||||
|
$this->seed(UserRoleSeeder::class);
|
||||||
|
|
||||||
|
$this->assertSame(4, UserRole::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_system_user_seeder_creates_a_locked_admin_account(): void
|
||||||
|
{
|
||||||
|
$this->seed(UserRoleSeeder::class);
|
||||||
|
$this->seed(SystemUserSeeder::class);
|
||||||
|
|
||||||
|
$system = User::where('email', SystemUserSeeder::EMAIL)->firstOrFail();
|
||||||
|
$this->assertTrue($system->is_system);
|
||||||
|
$this->assertFalse($system->must_change_password);
|
||||||
|
$this->assertSame(UserRole::ADMIN, $system->role->name);
|
||||||
|
$this->assertSame('complete', $system->setup_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_system_user_seeder_is_idempotent(): void
|
||||||
|
{
|
||||||
|
$this->seed(UserRoleSeeder::class);
|
||||||
|
$this->seed(SystemUserSeeder::class);
|
||||||
|
$this->seed(SystemUserSeeder::class);
|
||||||
|
|
||||||
|
$this->assertSame(1, User::where('email', SystemUserSeeder::EMAIL)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_database_seeder_runs_the_full_chain(): void
|
||||||
|
{
|
||||||
|
$this->seed(DatabaseSeeder::class);
|
||||||
|
|
||||||
|
$this->assertSame(4, UserRole::count());
|
||||||
|
$this->assertDatabaseHas('users', ['email' => SystemUserSeeder::EMAIL, 'is_system' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
backend/tests/Feature/UserControllerTest.php
Normal file
170
backend/tests/Feature/UserControllerTest.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/** @return list<int> id presenti in data della risposta. */
|
||||||
|
private function idsFrom(TestResponse $response): array
|
||||||
|
{
|
||||||
|
return collect($response->json('data'))->pluck('id')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gating ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_index_requires_authentication(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/users')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_is_forbidden_for_non_admin(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson('/api/users')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Index ----------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_index_returns_paginated_envelope(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson('/api/users')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'message',
|
||||||
|
'data',
|
||||||
|
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_filters_by_search_term(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$match = $this->operationalUser(['name' => 'Zzz Unique Marker']);
|
||||||
|
$other = $this->operationalUser(['name' => 'Someone Else']);
|
||||||
|
|
||||||
|
$ids = $this->idsFrom($this->getJson('/api/users?search=Unique+Marker')->assertOk());
|
||||||
|
|
||||||
|
$this->assertContains($match->id, $ids);
|
||||||
|
$this->assertNotContains($other->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_filters_by_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create();
|
||||||
|
$inRole = $this->operationalUser(['role_id' => $role->id]);
|
||||||
|
$outRole = $this->operationalUser();
|
||||||
|
|
||||||
|
$ids = $this->idsFrom($this->getJson("/api/users?role_id={$role->id}")->assertOk());
|
||||||
|
|
||||||
|
$this->assertContains($inRole->id, $ids);
|
||||||
|
$this->assertNotContains($outRole->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_excludes_trashed_by_default(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$trashed = $this->operationalUser();
|
||||||
|
$trashed->delete();
|
||||||
|
|
||||||
|
$ids = $this->idsFrom($this->getJson('/api/users')->assertOk());
|
||||||
|
|
||||||
|
$this->assertNotContains($trashed->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_can_include_trashed(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$active = $this->operationalUser();
|
||||||
|
$trashed = $this->operationalUser();
|
||||||
|
$trashed->delete();
|
||||||
|
|
||||||
|
$ids = $this->idsFrom($this->getJson('/api/users?trashed=with')->assertOk());
|
||||||
|
|
||||||
|
$this->assertContains($active->id, $ids);
|
||||||
|
$this->assertContains($trashed->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_can_return_only_trashed(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$active = $this->operationalUser();
|
||||||
|
$trashed = $this->operationalUser();
|
||||||
|
$trashed->delete();
|
||||||
|
|
||||||
|
$ids = $this->idsFrom($this->getJson('/api/users?trashed=only')->assertOk());
|
||||||
|
|
||||||
|
$this->assertContains($trashed->id, $ids);
|
||||||
|
$this->assertNotContains($active->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_respects_per_page(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson('/api/users?per_page=5')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.per_page', 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_rejects_an_excessive_per_page(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson('/api/users?per_page=500')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('per_page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_rejects_an_unknown_trashed_value(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->getJson('/api/users?trashed=garbage')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('trashed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Show -----------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_show_returns_a_user_with_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$user = $this->operationalUser();
|
||||||
|
|
||||||
|
$this->getJson("/api/users/{$user->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', $user->id)
|
||||||
|
->assertJsonPath('data.setup_status', 'complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_can_load_a_trashed_user(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$user = $this->operationalUser();
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$this->getJson("/api/users/{$user->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_is_forbidden_for_non_admin(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
$target = User::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson("/api/users/{$target->id}")->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
backend/tests/Feature/UserRoleControllerTest.php
Normal file
163
backend/tests/Feature/UserRoleControllerTest.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserRoleControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
// --- Lettura (tier.app) --------------------------------------------------
|
||||||
|
|
||||||
|
public function test_index_lists_roles_for_an_operational_user(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
UserRole::factory()->count(2)->create();
|
||||||
|
|
||||||
|
$this->getJson('/api/user-roles')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['message', 'data' => [['id', 'name', 'description']]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_returns_a_single_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson("/api/user-roles/{$role->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', $role->id)
|
||||||
|
->assertJsonPath('data.name', $role->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_usage_flags_system_role_as_locked(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
$role = UserRole::create(['name' => UserRole::SUPERVISOR]);
|
||||||
|
|
||||||
|
$this->getJson("/api/user-roles/{$role->id}/usage")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('in_use', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_usage_flags_free_custom_role_as_unlocked(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson("/api/user-roles/{$role->id}/usage")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('in_use', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||||
|
|
||||||
|
public function test_admin_can_create_a_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->postJson('/api/user-roles', ['name' => 'Editor', 'description' => 'Edits stuff'])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.name', 'Editor');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_roles', ['name' => 'Editor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_requires_a_name(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->postJson('/api/user-roles', ['description' => 'no name'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_rejects_a_duplicate_name(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
UserRole::factory()->create(['name' => 'Editor']);
|
||||||
|
|
||||||
|
$this->postJson('/api/user-roles', ['name' => 'Editor'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_update_a_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create(['name' => 'Old']);
|
||||||
|
|
||||||
|
$this->putJson("/api/user-roles/{$role->id}", ['name' => 'New'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.name', 'New');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_roles', ['id' => $role->id, 'name' => 'New']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_can_keep_the_same_name(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create(['name' => 'Stable']);
|
||||||
|
|
||||||
|
$this->putJson("/api/user-roles/{$role->id}", ['name' => 'Stable'])
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_rejects_a_name_taken_by_another_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
UserRole::factory()->create(['name' => 'Taken']);
|
||||||
|
$role = UserRole::factory()->create(['name' => 'Mine']);
|
||||||
|
|
||||||
|
$this->putJson("/api/user-roles/{$role->id}", ['name' => 'Taken'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_delete_a_system_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$role = UserRole::create(['name' => UserRole::GUEST]);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/user-roles/{$role->id}")
|
||||||
|
->assertStatus(409)
|
||||||
|
->assertJsonPath('in_use', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_roles', ['id' => $role->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_delete_a_role_in_use(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create(['name' => 'Busy']);
|
||||||
|
$this->operationalUser(['role_id' => $role->id]);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/user-roles/{$role->id}")
|
||||||
|
->assertStatus(409)
|
||||||
|
->assertJsonPath('in_use', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_roles', ['id' => $role->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_delete_a_free_custom_role(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->adminUser(), 'sanctum');
|
||||||
|
$role = UserRole::factory()->create(['name' => 'Disposable']);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/user-roles/{$role->id}")->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('user_roles', ['id' => $role->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gating ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_non_admin_cannot_write(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||||
|
|
||||||
|
$this->postJson('/api/user-roles', ['name' => 'Nope'])->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/tests/Support/UnsafeTestDatabaseException.php
Normal file
21
backend/tests/Support/UnsafeTestDatabaseException.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sollevata dal guard del base TestCase quando la suite sta per girare su un
|
||||||
|
* database che non è quello di test dedicato (`dyncoll_test`): blocca il run
|
||||||
|
* prima che RefreshDatabase possa azzerare il database di sviluppo.
|
||||||
|
*/
|
||||||
|
class UnsafeTestDatabaseException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function for(string $database): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
"Test isolation guard: connesso al DB '{$database}', atteso 'dyncoll_test'. "
|
||||||
|
.'Lancia i test con `make test` (punta al servizio db-test).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,30 @@
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\Concerns\CreatesUsers;
|
||||||
|
use Tests\Support\UnsafeTestDatabaseException;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
//
|
use CreatesUsers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cintura di sicurezza. refreshApplication() gira PRIMA di setUpTraits()
|
||||||
|
* (quindi prima dell'eventuale migrate:fresh di RefreshDatabase): se la
|
||||||
|
* connessione non punta al DB di test dedicato, abortiamo invece di azzerare
|
||||||
|
* il database di sviluppo `dyncoll`. (Non si può usare beforeRefreshingDatabase:
|
||||||
|
* nelle classi che usano il trait RefreshDatabase la sua versione vuota
|
||||||
|
* soppianta quella ereditata dal parent.) Esegui sempre con `make test`.
|
||||||
|
*/
|
||||||
|
protected function refreshApplication(): void
|
||||||
|
{
|
||||||
|
parent::refreshApplication();
|
||||||
|
|
||||||
|
$database = DB::connection()->getDatabaseName();
|
||||||
|
|
||||||
|
if ($database !== 'dyncoll_test') {
|
||||||
|
throw UnsafeTestDatabaseException::for($database);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* A basic test example.
|
|
||||||
*/
|
|
||||||
public function test_that_true_is_true(): void
|
|
||||||
{
|
|
||||||
$this->assertTrue(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,6 +42,41 @@ services:
|
|||||||
build:
|
build:
|
||||||
target: development
|
target: development
|
||||||
command: ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
command: ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
||||||
|
# db-test si avvia insieme al backend (merge col depends_on del compose base:
|
||||||
|
# db + redis). Così `make test` trova sempre il DB di test pronto senza doverlo
|
||||||
|
# accendere a mano. È in override → presente solo in dev/CI, mai in produzione.
|
||||||
|
depends_on:
|
||||||
|
db-test:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# DB dedicato ai test (SOLO dev/CI — sta qui in override, non nel compose base
|
||||||
|
# né in produzione). Isolamento FISICO dal db di sviluppo: la suite punta qui
|
||||||
|
# (DB_HOST=db-test, vedi target `make test`), così nessun RefreshDatabase può
|
||||||
|
# raggiungere e azzerare `dyncoll`. tmpfs = dati in RAM → effimeri e veloci.
|
||||||
|
# Non è una replica dei DATI: i test ricostruiscono lo schema dalle migration e
|
||||||
|
# vogliono un DB vuoto; serve solo lo stesso motore (mysql:8.4) del db di sviluppo.
|
||||||
|
db-test:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: dyncoll_db_test
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: dyncoll_test
|
||||||
|
MYSQL_USER: ${DB_USERNAME}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
# Stessa logica del db dev (docker-compose.yml): $$ = $ letterale espanso a
|
||||||
|
# runtime dalla shell del container; password quotata per i caratteri speciali.
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u \"$$MYSQL_USER\" -p\"$$MYSQL_PASSWORD\""]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
labels:
|
||||||
|
- "com.docker.compose.project=dyncoll-project"
|
||||||
|
|
||||||
# Mailpit: SMTP sink di sviluppo. Cattura tutte le mail (nessuna spedizione reale).
|
# Mailpit: SMTP sink di sviluppo. Cattura tutte le mail (nessuna spedizione reale).
|
||||||
# SMTP su 1025 (raggiungibile dal backend come host `mailpit`), Web UI su 8025.
|
# SMTP su 1025 (raggiungibile dal backend come host `mailpit`), Web UI su 8025.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
APP_KEY: ${APP_KEY}
|
APP_KEY: ${APP_KEY}
|
||||||
APP_DEBUG: ${APP_DEBUG}
|
APP_DEBUG: ${APP_DEBUG}
|
||||||
APP_ENV: ${APP_ENV}
|
APP_ENV: ${APP_ENV}
|
||||||
DB_CONNECTION: mysql
|
DB_procedCONNECTION: mysql
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
DB_PORT: ${DB_PORT}
|
DB_PORT: ${DB_PORT}
|
||||||
DB_DATABASE: ${DB_DATABASE}
|
DB_DATABASE: ${DB_DATABASE}
|
||||||
@@ -91,7 +91,10 @@ services:
|
|||||||
MYSQL_USER: ${DB_USERNAME}
|
MYSQL_USER: ${DB_USERNAME}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${DB_PORT}:3306"
|
# Porta PUBBLICATA sull'host (per client esterni) ≠ porta di connessione
|
||||||
|
# interna: i container raggiungono mysql su 3306 (DB_PORT), il forward host
|
||||||
|
# usa DB_HOST_PORT per non collidere con un MySQL nativo sulla 3306.
|
||||||
|
- "127.0.0.1:${DB_HOST_PORT}:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
# Nessun import di dump qui: i dati v2 arrivano da migration Laravel + ETL
|
# Nessun import di dump qui: i dati v2 arrivano da migration Laravel + ETL
|
||||||
@@ -126,7 +129,9 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- ${APP_BACKEND_PORT}:8000
|
# Solo loopback: in dev/prod l'accesso passa da Traefik/proxy, non serve
|
||||||
|
# esporre il backend sulla LAN.
|
||||||
|
- "127.0.0.1:${APP_BACKEND_PORT}:8000"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
26
frontend/.stylelintrc.json
Normal file
26
frontend/.stylelintrc.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": "stylelint-config-standard",
|
||||||
|
"rules": {
|
||||||
|
"declaration-property-value-no-unknown": true,
|
||||||
|
"import-notation": null,
|
||||||
|
"at-rule-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignoreAtRules": [
|
||||||
|
"theme",
|
||||||
|
"apply",
|
||||||
|
"tailwind",
|
||||||
|
"config",
|
||||||
|
"plugin",
|
||||||
|
"source",
|
||||||
|
"utility",
|
||||||
|
"variant",
|
||||||
|
"custom-variant",
|
||||||
|
"reference"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color-function-notation": null,
|
||||||
|
"alpha-value-notation": null
|
||||||
|
}
|
||||||
|
}
|
||||||
1361
frontend/package-lock.json
generated
1361
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,15 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"lint:css": "stylelint \"src/**/*.css\"",
|
||||||
|
"lint:css:fix": "stylelint --fix \"src/**/*.css\"",
|
||||||
"test": "vitest run --coverage --passWithNoTests",
|
"test": "vitest run --coverage --passWithNoTests",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"stylelint": "^17.13.0",
|
||||||
|
"stylelint-config-standard": "^40.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 87 KiB |
@@ -9,7 +9,15 @@ type IconName =
|
|||||||
| 'log-out'
|
| 'log-out'
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'layout-dashboard';
|
| 'layout-dashboard'
|
||||||
|
| 'pickaxe'
|
||||||
|
| 'box'
|
||||||
|
| 'landmark'
|
||||||
|
| 'book-user'
|
||||||
|
| 'history'
|
||||||
|
| 'list'
|
||||||
|
| 'mail-plus'
|
||||||
|
| 'images';
|
||||||
|
|
||||||
type Visibility =
|
type Visibility =
|
||||||
| 'always'
|
| 'always'
|
||||||
@@ -41,7 +49,7 @@ const menu: MenuItem[] = [
|
|||||||
{ id: 'legalLink', href: '/policy', ico: 'shield', label: 'Legal', visibility: 'always' },
|
{ id: 'legalLink', href: '/policy', ico: 'shield', label: 'Legal', visibility: 'always' },
|
||||||
{ id: 'docsLink', href: 'https://lunddarklab.github.io/adc/', ico: 'book-open', label: 'Docs', visibility: 'always' },
|
{ id: 'docsLink', href: 'https://lunddarklab.github.io/adc/', ico: 'book-open', label: 'Docs', visibility: 'always' },
|
||||||
{ id: 'loginLink', href: '/login', ico: 'log-in', label: 'Login', visibility: 'guest-only' },
|
{ id: 'loginLink', href: '/login', ico: 'log-in', label: 'Login', visibility: 'guest-only' },
|
||||||
{ id: 'userMenuToggle', href: '#', ico: 'menu', visibility: 'auth-only' },
|
{ id: 'user-menu-toggle', href: '#', ico: 'menu', visibility: 'auth-only' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@@ -55,9 +63,15 @@ export function getVisibleItems(isLoggedIn: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Menu utente (pannello laterale) -------------------------------------
|
// --- Menu utente (pannello laterale) -------------------------------------
|
||||||
// Mostrato solo agli utenti autenticati. Le voci con `roles` sono visibili
|
// Mostrato solo agli utenti autenticati. Le voci sono raggruppate in sezioni
|
||||||
// solo ai ruoli elencati; senza `roles` sono visibili a tutti i loggati.
|
// con titolo (come in dyncoll.v1/assets/menu.php). Sia i gruppi sia le singole
|
||||||
// Per spostare/aggiungere una voce basta intervenire qui, senza toccare il DOM.
|
// voci possono dichiarare `roles`: se presente, sono visibili solo ai ruoli
|
||||||
|
// elencati; se assente, a tutti i loggati. Per spostare/aggiungere una voce
|
||||||
|
// basta intervenire qui, senza toccare il DOM.
|
||||||
|
//
|
||||||
|
// NB: le "main pages" di v1 (home/map/credits/legal/db model) non sono replicate
|
||||||
|
// qui: in v2 l'header-menu è sempre visibile, quindi quei link non servono nel
|
||||||
|
// pannello. Gli href sono rotte placeholder, da allineare al routing reale.
|
||||||
|
|
||||||
type PanelAction = 'logout';
|
type PanelAction = 'logout';
|
||||||
|
|
||||||
@@ -70,35 +84,55 @@ export type UserPanelItem = {
|
|||||||
roles?: RoleId[]; // assente = tutti gli autenticati
|
roles?: RoleId[]; // assente = tutti gli autenticati
|
||||||
};
|
};
|
||||||
|
|
||||||
const userPanel: UserPanelItem[] = [
|
export type UserPanelGroup = {
|
||||||
{ id: 'dashboardLink', label: 'Dashboard', ico: 'layout-dashboard', href: '/dashboard' },
|
title?: string; // titolo di sezione; assente nel primo gruppo
|
||||||
{ id: 'profileLink', label: 'Profile', ico: 'user', href: '/profile' },
|
roles?: RoleId[]; // assente = tutti gli autenticati
|
||||||
{ id: 'adminLink', label: 'Admin', ico: 'settings', href: '/admin', roles: [ROLE.ADMIN, ROLE.SUPERVISOR] },
|
items: UserPanelItem[];
|
||||||
{ id: 'logoutBtn', label: 'Logout', ico: 'log-out', action: 'logout' },
|
};
|
||||||
|
|
||||||
|
const userPanel: UserPanelGroup[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ id: 'dashboardLink', label: 'Dashboard', ico: 'layout-dashboard', href: '/dashboard' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Add resource',
|
||||||
|
items: [
|
||||||
|
{ id: 'artifactAddLink', label: 'Artifact', ico: 'pickaxe', href: '/artifacts/add' },
|
||||||
|
{ id: 'modelAddLink', label: 'Model', ico: 'box', href: '/models/add' },
|
||||||
|
{ id: 'institutionAddLink', label: 'Institution', ico: 'landmark', href: '/institutions/add' },
|
||||||
|
{ id: 'personAddLink', label: 'Person', ico: 'book-user', href: '/persons/add' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Admin',
|
||||||
|
roles: [ROLE.ADMIN, ROLE.SUPERVISOR],
|
||||||
|
items: [
|
||||||
|
{ id: 'timelineLink', label: 'Timeline', ico: 'history', href: '/timeline' },
|
||||||
|
{ id: 'vocabulariesLink', label: 'Vocabularies', ico: 'list', href: '/vocabularies' },
|
||||||
|
{ id: 'mailComposerLink', label: 'Compose email', ico: 'mail-plus', href: '/mail/compose' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'My account',
|
||||||
|
items: [
|
||||||
|
{ id: 'settingsLink', label: 'Settings', ico: 'settings', href: '/settings' },
|
||||||
|
{ id: 'collectionsLink', label: 'My collections', ico: 'images', href: '#' },
|
||||||
|
{ id: 'logoutBtn', label: 'Logout', ico: 'log-out', action: 'logout' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getUserPanelItems(roleId: number): UserPanelItem[] {
|
// Ritorna i gruppi visibili al ruolo: filtra prima i gruppi, poi le singole voci,
|
||||||
return userPanel.filter(item => !item.roles || item.roles.includes(roleId as RoleId));
|
// scartando infine i gruppi rimasti vuoti.
|
||||||
}
|
export function getUserPanelGroups(roleId: number): UserPanelGroup[] {
|
||||||
|
const role = roleId as RoleId;
|
||||||
// Tipi per sidebar
|
return userPanel
|
||||||
// type SidebarAction = 'logout';
|
.filter(group => !group.roles || group.roles.includes(role))
|
||||||
// type SidebarItem = {
|
.map(group => ({
|
||||||
// id?: string;
|
...group,
|
||||||
// label: string;
|
items: group.items.filter(item => !item.roles || item.roles.includes(role)),
|
||||||
// ico: IconName;
|
}))
|
||||||
// href?: string; // se presente, la voce è un link
|
.filter(group => group.items.length > 0);
|
||||||
// action?: SidebarAction; // se presente, la voce è un pulsante con comportamento
|
}
|
||||||
// };
|
|
||||||
|
|
||||||
// Gruppo di voci del menu laterale. `roles` elenca i ruoli che vedono il gruppo:
|
|
||||||
// per spostare/aggiungere un link basta intervenire qui, senza toccare il DOM.
|
|
||||||
// type SidebarGroup = {
|
|
||||||
// title?: string; // titolo di sezione (header colorato); assente nel primo gruppo
|
|
||||||
// roles: RoleId[];
|
|
||||||
// items: SidebarItem[];
|
|
||||||
// };
|
|
||||||
|
|
||||||
// Menu laterale (utenti autenticati). I gruppi sono filtrati per ruolo:
|
|
||||||
// sposta una voce in un altro gruppo per cambiarne la visibilità.
|
|
||||||
// const ALL_AUTH: RoleId[] = [ROLE.ADMIN, ROLE.SUPERVISOR, ROLE.USER];
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { AuthUser } from "@/shared/auth";
|
import type { AuthUser } from "@/shared/auth";
|
||||||
import { logoutUser } from "@/shared/auth";
|
import { logoutUser } from "@/shared/auth";
|
||||||
import { getVisibleItems, getUserPanelItems, type UserPanelItem } from "./menuTypes";
|
import { getVisibleItems, getUserPanelGroups, type UserPanelItem } from "./menuTypes";
|
||||||
import { getCurrentDate } from "@/shared/utils";
|
import { getCurrentDate } from "@/shared/utils";
|
||||||
import {
|
import {
|
||||||
createIcons,
|
createIcons,
|
||||||
House, MapPin, Award, Shield, BookOpen, LogIn, Menu,
|
House, MapPin, Award, Shield, BookOpen, LogIn, Menu,
|
||||||
LogOut, User, Settings, LayoutDashboard,
|
LogOut, User, Settings, LayoutDashboard,
|
||||||
|
Pickaxe, Box, Landmark, BookUser, History, List, MailPlus, Images,
|
||||||
} from 'lucide';
|
} from 'lucide';
|
||||||
|
|
||||||
type Img = {
|
type Img = {
|
||||||
@@ -27,7 +28,11 @@ const footerImg: Img[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Set unico di icone Lucide usate nelle isole (header, pannello, footer).
|
// Set unico di icone Lucide usate nelle isole (header, pannello, footer).
|
||||||
const icons = { House, MapPin, Award, Shield, BookOpen, LogIn, Menu, LogOut, User, Settings, LayoutDashboard };
|
const icons = {
|
||||||
|
House, MapPin, Award, Shield, BookOpen, LogIn, Menu,
|
||||||
|
LogOut, User, Settings, LayoutDashboard,
|
||||||
|
Pickaxe, Box, Landmark, BookUser, History, List, MailPlus, Images,
|
||||||
|
};
|
||||||
|
|
||||||
export function renderUI(user: AuthUser | null): void {
|
export function renderUI(user: AuthUser | null): void {
|
||||||
setLogoHeader();
|
setLogoHeader();
|
||||||
@@ -92,7 +97,7 @@ function setHeaderMenu(user: AuthUser | null): void {
|
|||||||
function setFooterContent(user: AuthUser | null): void{
|
function setFooterContent(user: AuthUser | null): void{
|
||||||
const footerMenu = document.getElementById('footer-menu');
|
const footerMenu = document.getElementById('footer-menu');
|
||||||
if(footerMenu){
|
if(footerMenu){
|
||||||
buildMenu(footerMenu, getVisibleItems(user !== null), { exclude: ['userMenuToggle'] });
|
buildMenu(footerMenu, getVisibleItems(user !== null), { exclude: ['user-menu-toggle'] });
|
||||||
}
|
}
|
||||||
addFooterLogo();
|
addFooterLogo();
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,7 @@ function buildMenu(dom: HTMLElement, links: MenuLink[], opts: { exclude?: string
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pannello utente (solo loggati): pannello laterale da destra, sotto l'header,
|
// Pannello utente (solo loggati): pannello laterale da destra, sotto l'header,
|
||||||
// con backdrop leggero. L'hamburger (#userMenuToggle) resta sempre visibile e
|
// con backdrop leggero. L'hamburger (#user-menu-toggle) resta sempre visibile e
|
||||||
// fa da toggle. Replica il comportamento di v1 senza coprire l'header.
|
// fa da toggle. Replica il comportamento di v1 senza coprire l'header.
|
||||||
const PANEL_ID = 'user-panel';
|
const PANEL_ID = 'user-panel';
|
||||||
const BACKDROP_ID = 'user-panel-backdrop';
|
const BACKDROP_ID = 'user-panel-backdrop';
|
||||||
@@ -139,7 +144,7 @@ function setUserPanel(user: AuthUser | null): void {
|
|||||||
document.getElementById(BACKDROP_ID)?.remove();
|
document.getElementById(BACKDROP_ID)?.remove();
|
||||||
if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; }
|
if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; }
|
||||||
|
|
||||||
const toggle = document.getElementById('userMenuToggle') as HTMLAnchorElement | null;
|
const toggle = document.getElementById('user-menu-toggle') as HTMLAnchorElement | null;
|
||||||
if (!user || !toggle) return;
|
if (!user || !toggle) return;
|
||||||
|
|
||||||
// backdrop (parte sotto l'header, vedi CSS)
|
// backdrop (parte sotto l'header, vedi CSS)
|
||||||
@@ -156,8 +161,14 @@ function setUserPanel(user: AuthUser | null): void {
|
|||||||
|
|
||||||
const list = document.createElement('ul');
|
const list = document.createElement('ul');
|
||||||
list.className = 'menu w-full p-4';
|
list.className = 'menu w-full p-4';
|
||||||
getUserPanelItems(user.role_id).forEach(item => {
|
getUserPanelGroups(user.role_id).forEach(group => {
|
||||||
list.appendChild(buildPanelEntry(item));
|
if (group.title) {
|
||||||
|
const title = document.createElement('li');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = group.title;
|
||||||
|
list.appendChild(title);
|
||||||
|
}
|
||||||
|
group.items.forEach(item => list.appendChild(buildPanelEntry(item)));
|
||||||
});
|
});
|
||||||
panel.appendChild(list);
|
panel.appendChild(list);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
@import '@fontsource/rajdhani/500.css';
|
@import '@fontsource/rajdhani/500.css';
|
||||||
@import '@fontsource/rajdhani/700.css'; /* display/titoli, coerente col logo */
|
@import '@fontsource/rajdhani/700.css'; /* display/titoli, coerente col logo */
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "daisyui"; /* NOSONAR */
|
@plugin "daisyui"; /* NOSONAR */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Titillium Web", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Titillium Web", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-secondary: "Rajdhani", ui-sans-serif, system-ui, sans-serif;
|
--font-secondary: "Rajdhani", ui-sans-serif, system-ui, sans-serif;
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
--dc-secondary-dark: rgb(241, 146, 4);
|
--dc-secondary-dark: rgb(241, 146, 4);
|
||||||
--dc-white: rgb(255, 255, 255);
|
--dc-white: rgb(255, 255, 255);
|
||||||
--dc-dark-gray: rgb(52, 58, 64);
|
--dc-dark-gray: rgb(52, 58, 64);
|
||||||
|
--dc-light-gray: rgb(77,76,68,1);
|
||||||
}
|
}
|
||||||
|
|
||||||
body{
|
body{
|
||||||
@@ -48,6 +51,12 @@ header {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-panel li a,
|
||||||
|
.user-panel li button{
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
#header-menu a{
|
#header-menu a{
|
||||||
width:70px;
|
width:70px;
|
||||||
height:100%;
|
height:100%;
|
||||||
@@ -57,7 +66,7 @@ header {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-menu a:not(#userMenuToggle){
|
#header-menu a:not(#user-menu-toggle){
|
||||||
border-bottom: 4px solid transparent;
|
border-bottom: 4px solid transparent;
|
||||||
transition: all 500ms ease;
|
transition: all 500ms ease;
|
||||||
}
|
}
|
||||||
@@ -90,22 +99,16 @@ header {
|
|||||||
width:100%;
|
width:100%;
|
||||||
height:150px;
|
height:150px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-content: center;
|
place-content: center center;
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
#header-menu a:not(#userMenuToggle):hover{
|
|
||||||
border-bottom-color: var(--dc-white);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sotto md (48rem): voci più strette e label più piccola, così il testo resta
|
/* Sotto md (48rem): voci più strette e label più piccola, così il testo resta
|
||||||
sotto le icone senza mandare in overflow l'header su schermi piccoli. */
|
sotto le icone senza mandare in overflow l'header su schermi piccoli. */
|
||||||
@media (max-width: 47.999rem) {
|
@media (width <= 47.999rem) {
|
||||||
#header-menu a{
|
#header-menu a{
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-menu a span{
|
#header-menu a span{
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
@@ -121,36 +124,72 @@ header {
|
|||||||
top: var(--dc-header-h);
|
top: var(--dc-header-h);
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: min(20rem, 85vw);
|
width: min(15rem, 85vw);
|
||||||
z-index: 910;
|
z-index: 910;
|
||||||
background-color: var(--dc-white);
|
background-color: var(--dc-light-gray);
|
||||||
color: var(--dc-dark-gray);
|
color: var(--dc-white);
|
||||||
box-shadow: -4px 0 16px rgb(0 0 0 / 0.18);
|
box-shadow: -4px 0 16px rgb(0 0 0 / 0.18);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
transition: transform 300ms ease;
|
transition: transform 300ms ease;
|
||||||
|
padding:0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-panel.is-open{
|
.user-panel.is-open{
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.menu{ padding:0 }
|
||||||
|
|
||||||
|
ul.menu > li > *{
|
||||||
|
padding:10px 20px;
|
||||||
|
transition: all 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .menu-title{
|
||||||
|
background-color: var(--dc-dark-gray);
|
||||||
|
color: var(--dc-white);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li:not(.menu-title){
|
||||||
|
border-bottom: 1px solid var(--dc-dark-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voci del pannello utente: l'icona Lucide (svg, 24px di default) viene
|
||||||
|
dimensionata in em così segue il testo, e allineata verticalmente ad esso. */
|
||||||
|
|
||||||
|
|
||||||
|
.user-panel li :is(a, button) svg{
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.user-panel-backdrop{
|
.user-panel-backdrop{
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--dc-header-h);
|
inset: var(--dc-header-h) 0 0 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 900;
|
z-index: 900;
|
||||||
background-color: rgb(0 0 0 / 0.25);
|
background-color: rgb(0 0 0 / 0.25);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 300ms ease;
|
transition: opacity 300ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-panel-backdrop.is-open{
|
.user-panel-backdrop.is-open{
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
#header-menu a:not(#user-menu-toggle):hover{
|
||||||
|
border-bottom-color: var(--dc-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.menu > li > *:hover{ padding-left: 35px;}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.user-panel,
|
.user-panel,
|
||||||
.user-panel-backdrop{
|
.user-panel-backdrop{
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0">
|
|
||||||
<title>Dynamic Collection - Index</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div id="header-brand"></div>
|
|
||||||
<nav aria-label="Menu principale"></nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="main-container"></main>
|
|
||||||
|
|
||||||
<aside class="user-panel" aria-label="Area utente">
|
|
||||||
<div class="user-card"></div>
|
|
||||||
<nav aria-label="Menu utente"></nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<footer id="main-footer"></footer>
|
|
||||||
<script type="module" src="./src/pages/index/index.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -3,7 +3,6 @@ import type { Plugin } from 'vite'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { readdirSync } from 'node:fs'
|
import { readdirSync } from 'node:fs'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { visualizer } from 'rollup-plugin-visualizer'
|
|
||||||
|
|
||||||
// MPA: raccoglie automaticamente ogni *.html nella root di frontend/ come entry di build.
|
// MPA: raccoglie automaticamente ogni *.html nella root di frontend/ come entry di build.
|
||||||
// { 'index': '/app/index.html', 'scheda': '/app/scheda.html', ... }
|
// { 'index': '/app/index.html', 'scheda': '/app/scheda.html', ... }
|
||||||
@@ -44,7 +43,6 @@ function mpaRewritePlugin(): Plugin {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
visualizer(),
|
|
||||||
mpaRewritePlugin()
|
mpaRewritePlugin()
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user