diff --git a/.env.example b/.env.example index 7505ebe..cc63413 100644 --- a/.env.example +++ b/.env.example @@ -30,21 +30,18 @@ LOG_LEVEL=debug # warning/error in produzione # Usati sia da Laravel sia dal container mysql (mappati nel compose). DB_CONNECTION=mysql 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_USERNAME=dyncoll DB_PASSWORD= # segreto: compilare DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root) -# --- Database legacy (v1) — sorgente per l'ETL `php artisan v1:import` -------- -# Connessione in SOLA LETTURA verso il MySQL di dyncoll.v1. -# Attiva solo durante l'import; richiede rete docker condivisa o tunnel. -DB_LEGACY_CONNECTION=mysql -DB_LEGACY_HOST=dyncoll_v1_db # container/host del DB v1 -DB_LEGACY_PORT=3306 -DB_LEGACY_DATABASE=lund -DB_LEGACY_USERNAME=readonly -DB_LEGACY_PASSWORD= # segreto: compilare +# NB: le variabili DB_LEGACY_* (sorgente in SOLA LETTURA per l'ETL +# `php artisan v1:import`) NON stanno qui di proposito: servono solo durante la +# migrazione una-tantum da dyncoll.v1, non nella configurazione standard. Al +# momento del cutover aggiungerle a mano al .env con i valori corretti +# dell'ambiente (host/porta/credenziali possono differire dal locale). # --- Redis (cache, sessioni, code, Horizon) ---------------------------------- REDIS_HOST=redis @@ -62,6 +59,10 @@ FILESYSTEM_DISK=local # --- Autenticazione (Sanctum / Fortify) -------------------------------------- SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080 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) ------------------------------------------------------------- # DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025 diff --git a/Makefile b/Makefile index 716755d..9a66a0b 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ DC := docker compose EXEC := $(DC) exec -u $(UID):$(GID) backend .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 @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 tinker: ## REPL artisan (container) $(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 --- # 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 fe-build: ## build di produzione su host (smoke test locale) 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 --- permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum sudo chgrp -R www-data backend/storage backend/bootstrap/cache diff --git a/backend/app/Actions/Fortify/CreateNewUser.php b/backend/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000..ee2d712 --- /dev/null +++ b/backend/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,43 @@ + $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']), + ]); + } +} diff --git a/backend/app/Actions/Fortify/PasswordValidationRules.php b/backend/app/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000..3678865 --- /dev/null +++ b/backend/app/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,19 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } +} diff --git a/backend/app/Actions/Fortify/ResetUserPassword.php b/backend/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..667651f --- /dev/null +++ b/backend/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,32 @@ + $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(); + } +} diff --git a/backend/app/Actions/Fortify/UpdateUserPassword.php b/backend/app/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000..4a0306d --- /dev/null +++ b/backend/app/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,35 @@ + $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(); + } +} diff --git a/backend/app/Actions/Fortify/UpdateUserProfileInformation.php b/backend/app/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000..62f58fa --- /dev/null +++ b/backend/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,61 @@ + $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 $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/backend/app/Actions/PurgeUserAction.php b/backend/app/Actions/PurgeUserAction.php new file mode 100644 index 0000000..4a1e523 --- /dev/null +++ b/backend/app/Actions/PurgeUserAction.php @@ -0,0 +1,82 @@ + + */ + 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(); + } +} diff --git a/backend/app/Console/Commands/PurgeAnonymizableUsers.php b/backend/app/Console/Commands/PurgeAnonymizableUsers.php new file mode 100644 index 0000000..c431a12 --- /dev/null +++ b/backend/app/Console/Commands/PurgeAnonymizableUsers.php @@ -0,0 +1,54 @@ +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; + } +} diff --git a/backend/app/Exceptions/CannotDeleteSystemUserException.php b/backend/app/Exceptions/CannotDeleteSystemUserException.php new file mode 100644 index 0000000..3520904 --- /dev/null +++ b/backend/app/Exceptions/CannotDeleteSystemUserException.php @@ -0,0 +1,17 @@ +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')); + } +} diff --git a/backend/app/Http/Controllers/UserController.php b/backend/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..3a859c3 --- /dev/null +++ b/backend/app/Http/Controllers/UserController.php @@ -0,0 +1,85 @@ +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 $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 $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); + }); + } +} diff --git a/backend/app/Http/Controllers/UserRoleController.php b/backend/app/Http/Controllers/UserRoleController.php new file mode 100644 index 0000000..8b27d84 --- /dev/null +++ b/backend/app/Http/Controllers/UserRoleController.php @@ -0,0 +1,78 @@ +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(), + ]); + } +} diff --git a/backend/app/Http/Middleware/EnsureSetupComplete.php b/backend/app/Http/Middleware/EnsureSetupComplete.php new file mode 100644 index 0000000..1ba5131 --- /dev/null +++ b/backend/app/Http/Middleware/EnsureSetupComplete.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/app/Http/Middleware/ForceJsonResponse.php b/backend/app/Http/Middleware/ForceJsonResponse.php new file mode 100644 index 0000000..35ea080 --- /dev/null +++ b/backend/app/Http/Middleware/ForceJsonResponse.php @@ -0,0 +1,22 @@ +headers->set('Accept', 'application/json'); + + return $next($request); + } +} diff --git a/backend/app/Http/Middleware/IsAdmin.php b/backend/app/Http/Middleware/IsAdmin.php new file mode 100644 index 0000000..6770bb0 --- /dev/null +++ b/backend/app/Http/Middleware/IsAdmin.php @@ -0,0 +1,25 @@ +role?->name !== 'Admin') { + abort(403); + } + + return $next($request); + } +} diff --git a/backend/app/Http/Requests/IndexUserRequest.php b/backend/app/Http/Requests/IndexUserRequest.php new file mode 100644 index 0000000..9193980 --- /dev/null +++ b/backend/app/Http/Requests/IndexUserRequest.php @@ -0,0 +1,31 @@ + + */ + 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'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreUserRoleRequest.php b/backend/app/Http/Requests/StoreUserRoleRequest.php new file mode 100644 index 0000000..fb82574 --- /dev/null +++ b/backend/app/Http/Requests/StoreUserRoleRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255|unique:user_roles,name', + 'description' => 'nullable|string', + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateUserRoleRequest.php b/backend/app/Http/Requests/UpdateUserRoleRequest.php new file mode 100644 index 0000000..9fb4e69 --- /dev/null +++ b/backend/app/Http/Requests/UpdateUserRoleRequest.php @@ -0,0 +1,36 @@ +|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'], + ]; + } +} diff --git a/backend/app/Http/Traits/ApiResponse.php b/backend/app/Http/Traits/ApiResponse.php new file mode 100644 index 0000000..0f00549 --- /dev/null +++ b/backend/app/Http/Traits/ApiResponse.php @@ -0,0 +1,130 @@ +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); + } +} diff --git a/backend/app/Models/Lists/UserRole.php b/backend/app/Models/Lists/UserRole.php new file mode 100644 index 0000000..79536f5 --- /dev/null +++ b/backend/app/Models/Lists/UserRole.php @@ -0,0 +1,57 @@ + + */ + 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(); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index f6ba1d2..47024e4 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -2,20 +2,59 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; -use Database\Factories\UserFactory; -use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Attributes\Hidden; +use App\Models\Lists\UserRole; +use App\Notifications\ResetPasswordNotification; +use Illuminate\Contracts\Auth\MustVerifyEmail; 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\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; +use OwenIt\Auditing\Contracts\Auditable; -#[Fillable(['name', 'email', 'password'])] -#[Hidden(['password', 'remember_token'])] -class User extends Authenticatable +class User extends Authenticatable implements Auditable, MustVerifyEmail { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, SoftDeletes; + + use \OwenIt\Auditing\Auditable; + use TwoFactorAuthenticatable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + 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 + */ + protected $hidden = [ + 'password', + 'remember_token', + 'two_factor_secret', + 'two_factor_recovery_codes', + ]; /** * Get the attributes that should be cast. @@ -27,6 +66,43 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', '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 + */ + 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'; + } } diff --git a/backend/app/Notifications/ResetPasswordNotification.php b/backend/app/Notifications/ResetPasswordNotification.php new file mode 100644 index 0000000..c762c54 --- /dev/null +++ b/backend/app/Notifications/ResetPasswordNotification.php @@ -0,0 +1,54 @@ + + */ + 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 + */ + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/backend/app/Notifications/WelcomeNotification.php b/backend/app/Notifications/WelcomeNotification.php new file mode 100644 index 0000000..b6cdf05 --- /dev/null +++ b/backend/app/Notifications/WelcomeNotification.php @@ -0,0 +1,53 @@ + + */ + 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 + */ + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..d0e0d8c 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider */ 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())); } } diff --git a/backend/app/Providers/FortifyServiceProvider.php b/backend/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..e48ab1a --- /dev/null +++ b/backend/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,56 @@ +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() + ); + }); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 7a2848f..cd67ffb 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -1,18 +1,46 @@ withRouting( + api: __DIR__.'/../routes/api.php', // NOSONAR web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', 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 { - // + // 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 { $exceptions->shouldRenderJsonWhen( diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index fc94ae6..5ffd769 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -1,7 +1,9 @@ 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, +]; diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..f9d780e --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,45 @@ + ['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, + +]; diff --git a/backend/config/fortify.php b/backend/config/fortify.php new file mode 100644 index 0000000..1a87537 --- /dev/null +++ b/backend/config/fortify.php @@ -0,0 +1,160 @@ + '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, + ]), + ], + +]; diff --git a/backend/config/messages.php b/backend/config/messages.php new file mode 100644 index 0000000..743e1b5 --- /dev/null +++ b/backend/config/messages.php @@ -0,0 +1,37 @@ + ['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.', + ], + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..02c25f6 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,102 @@ + 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, + ], + +]; diff --git a/backend/database/factories/Lists/UserRoleFactory.php b/backend/database/factories/Lists/UserRoleFactory.php new file mode 100644 index 0000000..fa7f8b7 --- /dev/null +++ b/backend/database/factories/Lists/UserRoleFactory.php @@ -0,0 +1,25 @@ + + */ +class UserRoleFactory extends Factory +{ + protected $model = UserRole::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => ucfirst($this->faker->unique()->word()), + 'description' => $this->faker->sentence(), + ]; + } +} diff --git a/backend/database/migrations/2026_06_18_162047_add_users_soft_delete.php b/backend/database/migrations/2026_06_18_162047_add_users_soft_delete.php new file mode 100644 index 0000000..a7bda58 --- /dev/null +++ b/backend/database/migrations/2026_06_18_162047_add_users_soft_delete.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/backend/database/migrations/2026_06_18_162204_create_users_roles_table.php b/backend/database/migrations/2026_06_18_162204_create_users_roles_table.php new file mode 100644 index 0000000..6bee80c --- /dev/null +++ b/backend/database/migrations/2026_06_18_162204_create_users_roles_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name')->unique(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_roles'); + } +}; diff --git a/backend/database/migrations/2026_06_18_162324_add_role_id_to_users_table.php b/backend/database/migrations/2026_06_18_162324_add_role_id_to_users_table.php new file mode 100644 index 0000000..82d7c0d --- /dev/null +++ b/backend/database/migrations/2026_06_18_162324_add_role_id_to_users_table.php @@ -0,0 +1,27 @@ +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'); + }); + } +}; diff --git a/backend/database/migrations/2026_06_18_162420_add_must_change_password_to_users_table.php b/backend/database/migrations/2026_06_18_162420_add_must_change_password_to_users_table.php new file mode 100644 index 0000000..8895b48 --- /dev/null +++ b/backend/database/migrations/2026_06_18_162420_add_must_change_password_to_users_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/backend/database/migrations/2026_06_18_162631_add_is_system_and_anonymized_to_users_table.php b/backend/database/migrations/2026_06_18_162631_add_is_system_and_anonymized_to_users_table.php new file mode 100644 index 0000000..35e2fa9 --- /dev/null +++ b/backend/database/migrations/2026_06_18_162631_add_is_system_and_anonymized_to_users_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/backend/database/migrations/2026_06_19_071116_add_two_factor_columns_to_users_table.php b/backend/database/migrations/2026_06_19_071116_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..45739ef --- /dev/null +++ b/backend/database/migrations/2026_06_19_071116_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +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', + ]); + }); + } +}; diff --git a/backend/database/migrations/2026_06_19_073346_create_audits_table.php b/backend/database/migrations/2026_06_19_073346_create_audits_table.php new file mode 100644 index 0000000..1307f7d --- /dev/null +++ b/backend/database/migrations/2026_06_19_073346_create_audits_table.php @@ -0,0 +1,48 @@ +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); + } +}; diff --git a/backend/database/migrations/2026_06_19_080000_add_setup_2fa_completed_to_users_table.php b/backend/database/migrations/2026_06_19_080000_add_setup_2fa_completed_to_users_table.php new file mode 100644 index 0000000..2191a7f --- /dev/null +++ b/backend/database/migrations/2026_06_19_080000_add_setup_2fa_completed_to_users_table.php @@ -0,0 +1,27 @@ +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'); + }); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index 6b901f8..ba68f46 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -11,15 +10,14 @@ class DatabaseSeeder extends Seeder 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 { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + UserRoleSeeder::class, + SystemUserSeeder::class, ]); } } diff --git a/backend/database/seeders/SystemUserSeeder.php b/backend/database/seeders/SystemUserSeeder.php new file mode 100644 index 0000000..3d1db1d --- /dev/null +++ b/backend/database/seeders/SystemUserSeeder.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/backend/database/seeders/UserRoleSeeder.php b/backend/database/seeders/UserRoleSeeder.php new file mode 100644 index 0000000..ac4cb47 --- /dev/null +++ b/backend/database/seeders/UserRoleSeeder.php @@ -0,0 +1,42 @@ + 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.'] + ); + } +} diff --git a/backend/phpunit.xml b/backend/phpunit.xml index e7f0a48..bba6118 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -18,19 +18,25 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..dc41a62 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,36 @@ +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']); +}); diff --git a/backend/routes/api/user-roles.php b/backend/routes/api/user-roles.php new file mode 100644 index 0000000..3cec2cb --- /dev/null +++ b/backend/routes/api/user-roles.php @@ -0,0 +1,18 @@ +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']); +}); diff --git a/backend/routes/api/users.php b/backend/routes/api/users.php new file mode 100644 index 0000000..e89fcd3 --- /dev/null +++ b/backend/routes/api/users.php @@ -0,0 +1,11 @@ +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(); +}); diff --git a/backend/routes/console.php b/backend/routes/console.php index 3c9adf1..d078d5d 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Anonimizzazione GDPR degli utenti oltre la retention (richiede lo scheduler attivo). +Schedule::command('users:purge')->daily(); diff --git a/backend/tests/Concerns/CreatesUsers.php b/backend/tests/Concerns/CreatesUsers.php new file mode 100644 index 0000000..6f79abc --- /dev/null +++ b/backend/tests/Concerns/CreatesUsers.php @@ -0,0 +1,66 @@ + $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)); + } +} diff --git a/backend/tests/Feature/Auth/AccessTierTest.php b/backend/tests/Feature/Auth/AccessTierTest.php new file mode 100644 index 0000000..0a10e6b --- /dev/null +++ b/backend/tests/Feature/Auth/AccessTierTest.php @@ -0,0 +1,73 @@ +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(); + } +} diff --git a/backend/tests/Feature/Auth/AuthControllerTest.php b/backend/tests/Feature/Auth/AuthControllerTest.php new file mode 100644 index 0000000..dc4552c --- /dev/null +++ b/backend/tests/Feature/Auth/AuthControllerTest.php @@ -0,0 +1,86 @@ +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(); + } +} diff --git a/backend/tests/Feature/EnvironmentSafetyTest.php b/backend/tests/Feature/EnvironmentSafetyTest.php new file mode 100644 index 0000000..ac3e552 --- /dev/null +++ b/backend/tests/Feature/EnvironmentSafetyTest.php @@ -0,0 +1,24 @@ +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()); + } +} diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/backend/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php b/backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php new file mode 100644 index 0000000..ac574cf --- /dev/null +++ b/backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php @@ -0,0 +1,77 @@ +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(); + } +} diff --git a/backend/tests/Feature/PurgeUserActionTest.php b/backend/tests/Feature/PurgeUserActionTest.php new file mode 100644 index 0000000..d4907b3 --- /dev/null +++ b/backend/tests/Feature/PurgeUserActionTest.php @@ -0,0 +1,55 @@ +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); + } +} diff --git a/backend/tests/Feature/SeederTest.php b/backend/tests/Feature/SeederTest.php new file mode 100644 index 0000000..cd70c43 --- /dev/null +++ b/backend/tests/Feature/SeederTest.php @@ -0,0 +1,73 @@ +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]); + } +} diff --git a/backend/tests/Feature/UserControllerTest.php b/backend/tests/Feature/UserControllerTest.php new file mode 100644 index 0000000..aa1007c --- /dev/null +++ b/backend/tests/Feature/UserControllerTest.php @@ -0,0 +1,170 @@ + 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(); + } +} diff --git a/backend/tests/Feature/UserRoleControllerTest.php b/backend/tests/Feature/UserRoleControllerTest.php new file mode 100644 index 0000000..d5a8397 --- /dev/null +++ b/backend/tests/Feature/UserRoleControllerTest.php @@ -0,0 +1,163 @@ +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(); + } +} diff --git a/backend/tests/Support/UnsafeTestDatabaseException.php b/backend/tests/Support/UnsafeTestDatabaseException.php new file mode 100644 index 0000000..6297fed --- /dev/null +++ b/backend/tests/Support/UnsafeTestDatabaseException.php @@ -0,0 +1,21 @@ +getDatabaseName(); + + if ($database !== 'dyncoll_test') { + throw UnsafeTestDatabaseException::for($database); + } + } } diff --git a/backend/tests/Unit/ExampleTest.php b/backend/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/backend/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 935f779..a5a6791 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -42,6 +42,41 @@ services: build: target: development 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). # SMTP su 1025 (raggiungibile dal backend come host `mailpit`), Web UI su 8025. diff --git a/docker-compose.yml b/docker-compose.yml index 7e17031..53a7556 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: APP_KEY: ${APP_KEY} APP_DEBUG: ${APP_DEBUG} APP_ENV: ${APP_ENV} - DB_CONNECTION: mysql + DB_procedCONNECTION: mysql DB_HOST: db DB_PORT: ${DB_PORT} DB_DATABASE: ${DB_DATABASE} @@ -91,7 +91,10 @@ services: MYSQL_USER: ${DB_USERNAME} MYSQL_PASSWORD: ${DB_PASSWORD} 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: - mysql_data:/var/lib/mysql # Nessun import di dump qui: i dati v2 arrivano da migration Laravel + ETL @@ -126,7 +129,9 @@ services: redis: condition: service_healthy 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: - internal volumes: diff --git a/frontend/.stylelintrc.json b/frontend/.stylelintrc.json new file mode 100644 index 0000000..cd46ad4 --- /dev/null +++ b/frontend/.stylelintrc.json @@ -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 + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f66bd5..3efa448 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,12 +23,34 @@ "@vitest/coverage-v8": "^4.1.8", "daisyui": "^5.5.23", "rollup-plugin-visualizer": "^7.0.1", + "stylelint": "^17.13.0", + "stylelint-config-standard": "^40.0.0", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.8" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -84,6 +106,183 @@ "node": ">=18" } }, + "node_modules/@cacheable/memory": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz", + "integrity": "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==", + "dev": true, + "dependencies": { + "@cacheable/utils": "^2.4.1", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "dev": true, + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", + "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", + "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -176,6 +375,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true + }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", @@ -199,6 +420,41 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -456,6 +712,18 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -922,6 +1190,22 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -946,6 +1230,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -966,6 +1256,15 @@ "js-tokens": "^10.0.0" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -982,6 +1281,18 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -997,6 +1308,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cacheable": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", + "integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==", + "dev": true, + "dependencies": { + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.1", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1009,6 +1333,15 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1051,6 +1384,30 @@ "node": ">=20" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1068,6 +1425,66 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/daisyui": { "version": "5.5.23", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.23.tgz", @@ -1182,6 +1599,24 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1256,6 +1691,62 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1273,6 +1764,44 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz", + "integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==", + "dev": true, + "dependencies": { + "flat-cache": "^6.1.22" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "6.1.22", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", + "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", + "dev": true, + "dependencies": { + "cacheable": "^2.3.4", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1385,6 +1914,70 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1436,6 +2029,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1447,12 +2052,30 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html-tags": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", + "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", + "dev": true, + "engines": { + "node": ">=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1465,6 +2088,53 @@ "node": ">= 6" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1480,6 +2150,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -1510,6 +2210,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -1525,6 +2246,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -1576,6 +2303,58 @@ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -1830,6 +2609,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true + }, "node_modules/lucide": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.18.0.tgz", @@ -1878,6 +2669,68 @@ "node": ">= 0.4" } }, + "node_modules/mathml-tag-names": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", + "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1920,6 +2773,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -1953,6 +2815,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2005,6 +2897,51 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -2025,6 +2962,72 @@ "node": ">=10" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -2100,6 +3103,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -2118,6 +3144,62 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -2180,6 +3262,128 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/stylelint": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.13.0.tgz", + "integrity": "sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.4", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0", + "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-specificity": "^6.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^9.0.1", + "css-functions-list": "^3.3.3", + "css-tree": "^3.2.1", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^11.1.3", + "global-modules": "^2.0.0", + "globby": "^16.2.0", + "globjoin": "^0.1.4", + "html-tags": "^5.1.0", + "ignore": "^7.0.5", + "import-meta-resolve": "^4.2.0", + "mathml-tag-names": "^4.0.0", + "meow": "^14.1.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.15", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.1", + "postcss-value-parser": "^4.2.0", + "string-width": "^8.2.1", + "supports-hyperlinks": "^4.4.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^7.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/stylelint-config-recommended": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", + "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, + "node_modules/stylelint-config-standard": { + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz", + "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "dependencies": { + "stylelint-config-recommended": "^18.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, + "node_modules/stylelint/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2192,6 +3396,109 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", + "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", + "dev": true, + "dependencies": { + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -2251,6 +3558,18 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2277,6 +3596,24 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -2443,6 +3780,18 @@ } } }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2476,6 +3825,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "dev": true, + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 96d5023..66bbf9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,11 +8,15 @@ "build": "vite build", "preview": "vite preview", "type-check": "tsc --noEmit", + "lint:css": "stylelint \"src/**/*.css\"", + "lint:css:fix": "stylelint --fix \"src/**/*.css\"", "test": "vitest run --coverage --passWithNoTests", "test:watch": "vitest" }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", + "stylelint": "^17.13.0", + "stylelint-config-standard": "^40.0.0", "@types/leaflet": "^1.9.21", "@types/node": "^25.9.3", "@vitest/coverage-v8": "^4.1.8", diff --git a/frontend/public/logo/logo_dyncoll.png b/frontend/public/logo/logo_dyncoll.png index c5e9170..79e5730 100644 Binary files a/frontend/public/logo/logo_dyncoll.png and b/frontend/public/logo/logo_dyncoll.png differ diff --git a/frontend/src/config/menuTypes.ts b/frontend/src/config/menuTypes.ts index 2a723e7..e27777a 100644 --- a/frontend/src/config/menuTypes.ts +++ b/frontend/src/config/menuTypes.ts @@ -9,7 +9,15 @@ type IconName = | 'log-out' | 'user' | 'settings' - | 'layout-dashboard'; + | 'layout-dashboard' + | 'pickaxe' + | 'box' + | 'landmark' + | 'book-user' + | 'history' + | 'list' + | 'mail-plus' + | 'images'; type Visibility = | 'always' @@ -41,7 +49,7 @@ const menu: MenuItem[] = [ { 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: '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) ------------------------------------- -// Mostrato solo agli utenti autenticati. Le voci con `roles` sono visibili -// solo ai ruoli elencati; senza `roles` sono visibili a tutti i loggati. -// Per spostare/aggiungere una voce basta intervenire qui, senza toccare il DOM. +// Mostrato solo agli utenti autenticati. Le voci sono raggruppate in sezioni +// con titolo (come in dyncoll.v1/assets/menu.php). Sia i gruppi sia le singole +// 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'; @@ -70,35 +84,55 @@ export type UserPanelItem = { roles?: RoleId[]; // assente = tutti gli autenticati }; -const userPanel: UserPanelItem[] = [ - { id: 'dashboardLink', label: 'Dashboard', ico: 'layout-dashboard', href: '/dashboard' }, - { id: 'profileLink', label: 'Profile', ico: 'user', href: '/profile' }, - { id: 'adminLink', label: 'Admin', ico: 'settings', href: '/admin', roles: [ROLE.ADMIN, ROLE.SUPERVISOR] }, - { id: 'logoutBtn', label: 'Logout', ico: 'log-out', action: 'logout' }, +export type UserPanelGroup = { + title?: string; // titolo di sezione; assente nel primo gruppo + roles?: RoleId[]; // assente = tutti gli autenticati + items: UserPanelItem[]; +}; + +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[] { - return userPanel.filter(item => !item.roles || item.roles.includes(roleId as RoleId)); -} - -// Tipi per sidebar -// type SidebarAction = 'logout'; -// type SidebarItem = { -// id?: string; -// label: string; -// ico: IconName; -// href?: string; // se presente, la voce è un link -// 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]; \ No newline at end of file +// Ritorna i gruppi visibili al ruolo: filtra prima i gruppi, poi le singole voci, +// scartando infine i gruppi rimasti vuoti. +export function getUserPanelGroups(roleId: number): UserPanelGroup[] { + const role = roleId as RoleId; + return userPanel + .filter(group => !group.roles || group.roles.includes(role)) + .map(group => ({ + ...group, + items: group.items.filter(item => !item.roles || item.roles.includes(role)), + })) + .filter(group => group.items.length > 0); +} \ No newline at end of file diff --git a/frontend/src/config/ui.ts b/frontend/src/config/ui.ts index 780895a..a827aa5 100644 --- a/frontend/src/config/ui.ts +++ b/frontend/src/config/ui.ts @@ -1,11 +1,12 @@ import type { AuthUser } 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 { createIcons, House, MapPin, Award, Shield, BookOpen, LogIn, Menu, LogOut, User, Settings, LayoutDashboard, + Pickaxe, Box, Landmark, BookUser, History, List, MailPlus, Images, } from 'lucide'; type Img = { @@ -27,7 +28,11 @@ const footerImg: Img[] = [ ]; // 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 { setLogoHeader(); @@ -92,7 +97,7 @@ function setHeaderMenu(user: AuthUser | null): void { function setFooterContent(user: AuthUser | null): void{ const footerMenu = document.getElementById('footer-menu'); if(footerMenu){ - buildMenu(footerMenu, getVisibleItems(user !== null), { exclude: ['userMenuToggle'] }); + buildMenu(footerMenu, getVisibleItems(user !== null), { exclude: ['user-menu-toggle'] }); } 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, -// 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. const PANEL_ID = 'user-panel'; const BACKDROP_ID = 'user-panel-backdrop'; @@ -139,7 +144,7 @@ function setUserPanel(user: AuthUser | null): void { document.getElementById(BACKDROP_ID)?.remove(); 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; // backdrop (parte sotto l'header, vedi CSS) @@ -156,8 +161,14 @@ function setUserPanel(user: AuthUser | null): void { const list = document.createElement('ul'); list.className = 'menu w-full p-4'; - getUserPanelItems(user.role_id).forEach(item => { - list.appendChild(buildPanelEntry(item)); + getUserPanelGroups(user.role_id).forEach(group => { + 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); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index c8019e6..3ef5233 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -3,7 +3,9 @@ @import '@fontsource/rajdhani/500.css'; @import '@fontsource/rajdhani/700.css'; /* display/titoli, coerente col logo */ @import "tailwindcss"; + @plugin "daisyui"; /* NOSONAR */ + @theme { --font-sans: "Titillium Web", 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-white: rgb(255, 255, 255); --dc-dark-gray: rgb(52, 58, 64); + --dc-light-gray: rgb(77,76,68,1); } body{ @@ -48,6 +51,12 @@ header { justify-content: center; gap: 3px; } + +.user-panel li a, +.user-panel li button{ + align-items: center; +} + #header-menu a{ width:70px; height:100%; @@ -57,7 +66,7 @@ header { justify-content: center; } -#header-menu a:not(#userMenuToggle){ +#header-menu a:not(#user-menu-toggle){ border-bottom: 4px solid transparent; transition: all 500ms ease; } @@ -90,22 +99,16 @@ header { width:100%; height:150px; display: flex; - align-content: center; - justify-content: center; -} - -@media (hover: hover) and (pointer: fine) { - #header-menu a:not(#userMenuToggle):hover{ - border-bottom-color: var(--dc-white); - } + place-content: center center; } /* 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. */ -@media (max-width: 47.999rem) { +@media (width <= 47.999rem) { #header-menu a{ width: 3rem; } + #header-menu a span{ font-size: 0.6rem; line-height: 1.1; @@ -121,36 +124,72 @@ header { top: var(--dc-header-h); right: 0; bottom: 0; - width: min(20rem, 85vw); + width: min(15rem, 85vw); z-index: 910; - background-color: var(--dc-white); - color: var(--dc-dark-gray); + background-color: var(--dc-light-gray); + color: var(--dc-white); box-shadow: -4px 0 16px rgb(0 0 0 / 0.18); overflow-y: auto; transform: translateX(100%); transition: transform 300ms ease; + padding:0; } + .user-panel.is-open{ 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{ position: fixed; - top: var(--dc-header-h); - left: 0; - right: 0; - bottom: 0; + inset: var(--dc-header-h) 0 0 0; z-index: 900; background-color: rgb(0 0 0 / 0.25); opacity: 0; pointer-events: none; transition: opacity 300ms ease; } + .user-panel-backdrop.is-open{ opacity: 1; 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) { .user-panel, .user-panel-backdrop{ diff --git a/frontend/template.html.example b/frontend/template.html.example deleted file mode 100644 index 4de9ffa..0000000 --- a/frontend/template.html.example +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - Dynamic Collection - Index - - -
-
- -
- -
- - - -
- - - \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1ab055c..6943307 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,7 +3,6 @@ import type { Plugin } from 'vite' import { fileURLToPath } from 'node:url' import { readdirSync } from 'node:fs' import tailwindcss from '@tailwindcss/vite' -import { visualizer } from 'rollup-plugin-visualizer' // MPA: raccoglie automaticamente ogni *.html nella root di frontend/ come entry di build. // { 'index': '/app/index.html', 'scheda': '/app/scheda.html', ... } @@ -44,7 +43,6 @@ function mpaRewritePlugin(): Plugin { export default defineConfig({ plugins: [ tailwindcss(), - visualizer(), mpaRewritePlugin() ], resolve: {