feat: establish reservation portal foundation

This commit is contained in:
2026-08-04 15:24:08 +01:00
commit c96264ae96
226 changed files with 32232 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domain\Audit\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AuditLog extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'actor_id', 'event', 'auditable_type', 'auditable_id', 'ip_address',
'user_agent', 'before', 'after', 'metadata', 'created_at',
];
protected function casts(): array
{
return ['before' => 'array', 'after' => 'array', 'metadata' => 'array', 'created_at' => 'datetime'];
}
/** @return BelongsTo<User, $this> */
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
/** @return MorphTo<Model, $this> */
public function auditable(): MorphTo
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Identity\Enums;
enum RoleName: string
{
case Requester = 'requester';
case Approver = 'approver';
case InventoryOperator = 'inventory_operator';
case Administrator = 'administrator';
case Auditor = 'auditor';
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domain\Identity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
protected $fillable = ['name', 'description'];
/** @return BelongsToMany<Role, $this> */
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domain\Identity\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends Model
{
protected $fillable = ['name', 'description'];
/** @return BelongsToMany<User, $this> */
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
/** @return BelongsToMany<Permission, $this> */
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domain\Inventory\Models;
use App\Domain\Reservations\Models\AssetBlock;
use App\Domain\Reservations\Models\ReservationItem;
use Database\Factories\SnipeAssetFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[UseFactory(SnipeAssetFactory::class)]
class SnipeAsset extends Model
{
/** @use HasFactory<SnipeAssetFactory> */
use HasFactory;
protected $fillable = [
'snipe_asset_id', 'asset_tag', 'name', 'serial', 'model_id', 'model_name',
'category_id', 'category_name', 'manufacturer', 'status_id', 'status_name',
'location_id', 'location_name', 'requestable', 'assigned_type', 'assigned_id',
'image_url', 'source_updated_at', 'synced_at', 'content_hash', 'active',
];
protected function casts(): array
{
return [
'requestable' => 'boolean',
'active' => 'boolean',
'source_updated_at' => 'datetime',
'synced_at' => 'datetime',
];
}
/** @return HasMany<ReservationItem, $this> */
public function reservationItems(): HasMany
{
return $this->hasMany(ReservationItem::class);
}
/** @return HasMany<AssetBlock, $this> */
public function blocks(): HasMany
{
return $this->hasMany(AssetBlock::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Reservations\Enums;
enum ReservationItemStatus: string
{
case Held = 'held';
case Reserved = 'reserved';
case Ready = 'ready';
case Delivered = 'delivered';
case Returned = 'returned';
case Cancelled = 'cancelled';
case Failed = 'failed';
/** @return list<string> */
public static function blockingValues(): array
{
return [self::Held->value, self::Reserved->value, self::Ready->value, self::Delivered->value];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Reservations\Enums;
enum ReservationStatus: string
{
case Draft = 'draft';
case PendingApproval = 'pending_approval';
case AwaitingInformation = 'awaiting_information';
case Approved = 'approved';
case Ready = 'ready';
case Delivered = 'delivered';
case InUse = 'in_use';
case Returned = 'returned';
case Completed = 'completed';
case Rejected = 'rejected';
case Cancelled = 'cancelled';
case Expired = 'expired';
case NoShow = 'no_show';
case Overdue = 'overdue';
case Incident = 'incident';
/** @return list<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::PendingApproval, self::Cancelled],
self::PendingApproval => [self::AwaitingInformation, self::Approved, self::Rejected, self::Cancelled, self::Expired],
self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired],
self::Approved => [self::Ready, self::Cancelled],
self::Ready => [self::Delivered, self::NoShow, self::Cancelled],
self::Delivered => [self::InUse, self::Returned, self::Overdue, self::Incident],
self::InUse, self::Overdue, self::Incident => [self::Returned],
self::Returned => [self::Completed, self::Incident],
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), true);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domain\Reservations\Models;
use App\Domain\Inventory\Models\SnipeAsset;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AssetBlock extends Model
{
protected $fillable = ['snipe_asset_id', 'starts_at', 'ends_at', 'reason', 'created_by'];
protected function casts(): array
{
return ['starts_at' => 'immutable_datetime', 'ends_at' => 'immutable_datetime'];
}
/** @return BelongsTo<SnipeAsset, $this> */
public function asset(): BelongsTo
{
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domain\Reservations\Models;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Reservation extends Model
{
use HasUuids;
protected $fillable = [
'uuid', 'reference', 'requester_id', 'purpose', 'usage_location', 'starts_at',
'ends_at', 'status', 'approval_deadline', 'approved_by', 'approved_at',
'rejected_by', 'rejected_at', 'decision_reason', 'cancelled_by',
'cancelled_at', 'cancellation_reason',
];
/** @return list<string> */
public function uniqueIds(): array
{
return ['uuid'];
}
protected function casts(): array
{
return [
'status' => ReservationStatus::class,
'starts_at' => 'immutable_datetime',
'ends_at' => 'immutable_datetime',
'approval_deadline' => 'immutable_datetime',
'approved_at' => 'immutable_datetime',
'rejected_at' => 'immutable_datetime',
'cancelled_at' => 'immutable_datetime',
];
}
/** @return BelongsTo<User, $this> */
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requester_id');
}
/** @return HasMany<ReservationItem, $this> */
public function items(): HasMany
{
return $this->hasMany(ReservationItem::class);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domain\Reservations\Models;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReservationItem extends Model
{
protected $fillable = [
'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'status',
'delivered_at', 'returned_at', 'checkout_reference', 'checkin_reference',
'condition_out', 'condition_in', 'notes',
];
protected function casts(): array
{
return [
'status' => ReservationItemStatus::class,
'starts_at' => 'immutable_datetime',
'ends_at' => 'immutable_datetime',
'delivered_at' => 'immutable_datetime',
'returned_at' => 'immutable_datetime',
];
}
/** @return BelongsTo<Reservation, $this> */
public function reservation(): BelongsTo
{
return $this->belongsTo(Reservation::class);
}
/** @return BelongsTo<SnipeAsset, $this> */
public function asset(): BelongsTo
{
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domain\Reservations\Services;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use DateTimeInterface;
class AvailabilityService
{
public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool
{
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {
return false;
}
$hasReservationConflict = $asset->reservationItems()
->whereIn('status', ReservationItemStatus::blockingValues())
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->exists();
if ($hasReservationConflict) {
return false;
}
return ! $asset->blocks()
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->exists();
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Domain\Reservations\Services;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ReservationService
{
public function __construct(private readonly AvailabilityService $availability) {}
/**
* @param array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list<int>} $data
*/
public function createPending(User $requester, array $data): Reservation
{
$startsAt = CarbonImmutable::parse($data['starts_at']);
$endsAt = CarbonImmutable::parse($data['ends_at']);
return DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation {
$assets = SnipeAsset::query()
->whereKey($data['asset_ids'])
->lockForUpdate()
->get();
if ($assets->count() !== count(array_unique($data['asset_ids']))) {
throw ValidationException::withMessages(['asset_ids' => 'Um ou mais equipamentos já não estão disponíveis.']);
}
$unavailable = $assets->filter(
fn (SnipeAsset $asset): bool => ! $this->availability->isAvailable($asset, $startsAt, $endsAt)
);
if ($unavailable->isNotEmpty()) {
throw ValidationException::withMessages([
'asset_ids' => 'Conflito de disponibilidade: '.$unavailable->pluck('name')->join(', ').'.',
]);
}
$reservation = Reservation::create([
'reference' => $this->nextReference(),
'requester_id' => $requester->id,
'purpose' => $data['purpose'],
'usage_location' => $data['usage_location'],
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->addHours(48),
]);
foreach ($assets as $asset) {
$reservation->items()->create([
'snipe_asset_id' => $asset->id,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'status' => ReservationItemStatus::Held,
]);
}
AuditLog::create([
'actor_id' => $requester->id,
'event' => 'reservation.submitted',
'auditable_type' => $reservation->getMorphClass(),
'auditable_id' => $reservation->id,
'after' => $reservation->only(['reference', 'purpose', 'usage_location', 'starts_at', 'ends_at', 'status']),
'metadata' => ['asset_ids' => $assets->modelKeys()],
]);
return $reservation->load('items.asset');
}, 3);
}
private function nextReference(): string
{
do {
$reference = 'REQ-'.now()->format('Ymd').'-'.Str::upper(Str::random(6));
} while (Reservation::where('reference', $reference)->exists());
return $reference;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Inventory\Models\SnipeAsset;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AssetController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'category' => ['nullable', 'string', 'max:100'],
'location' => ['nullable', 'string', 'max:100'],
]);
$assets = SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->when($filters['search'] ?? null, function ($query, string $search) {
$query->where(function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%")
->orWhere('asset_tag', 'like', "%{$search}%")
->orWhere('model_name', 'like', "%{$search}%");
});
})
->when($filters['category'] ?? null, fn ($query, string $category) => $query->where('category_name', $category))
->when($filters['location'] ?? null, fn ($query, string $location) => $query->where('location_name', $location))
->orderBy('name')
->paginate(12)
->withQueryString();
return Inertia::render('assets/index', [
'assets' => $assets,
'filters' => $filters,
'categories' => SnipeAsset::query()->where('active', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'),
'locations' => SnipeAsset::query()->where('active', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'),
'lastSyncedAt' => SnipeAsset::max('synced_at'),
]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Reservations\Enums\ReservationStatus;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(Request $request): Response
{
$query = $request->user()->reservations();
$next = (clone $query)
->with('items.asset:id,name,asset_tag,category_name')
->where('ends_at', '>', now())
->whereNotIn('status', [ReservationStatus::Rejected, ReservationStatus::Cancelled, ReservationStatus::Expired])
->orderBy('starts_at')
->first();
return Inertia::render('dashboard', [
'summary' => [
'pending' => (clone $query)->where('status', ReservationStatus::PendingApproval)->count(),
'approved' => (clone $query)->whereIn('status', [ReservationStatus::Approved, ReservationStatus::Ready])->count(),
'inUse' => (clone $query)->whereIn('status', [ReservationStatus::Delivered, ReservationStatus::InUse, ReservationStatus::Overdue])->count(),
],
'nextReservation' => $next,
'recentReservations' => (clone $query)
->with('items.asset:id,name,asset_tag')
->latest()
->limit(5)
->get(),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Services\ReservationService;
use App\Http\Requests\StoreReservationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ReservationController extends Controller
{
public function index(Request $request): Response
{
return Inertia::render('reservations/index', [
'reservations' => $request->user()->reservations()
->with('items.asset:id,name,asset_tag')
->latest()
->paginate(15),
]);
}
public function create(Request $request): Response
{
return Inertia::render('reservations/create', [
'assets' => SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->orderBy('name')
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']),
'selectedAssetId' => $request->integer('asset'),
]);
}
public function store(StoreReservationRequest $request, ReservationService $service): RedirectResponse
{
/** @var array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list<int>} $data */
$data = $request->safe()->only(['purpose', 'usage_location', 'starts_at', 'ends_at', 'asset_ids']);
$reservation = $service->createPending($request->user(), $data);
return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação.");
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
return to_route('profile.edit');
}
/**
* Delete the user's profile.
*/
public function destroy(ProfileDeleteRequest $request): RedirectResponse
{
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\PasswordUpdateRequest;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class SecurityController extends Controller
{
/**
* Show the user's security settings page.
*/
public function edit(TwoFactorAuthenticationRequest $request): Response
{
$props = [
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
'canManagePasskeys' => Features::canManagePasskeys(),
'passkeys' => Features::canManagePasskeys()
? $request->user()
->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->values()
->all()
: [],
'passwordRules' => Password::defaults()->toPasswordRulesString(),
];
if (Features::canManageTwoFactorAuthentication()) {
$request->ensureStateIsValid();
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
return Inertia::render('settings/security', $props);
}
/**
* Update the user's password.
*/
public function update(PasswordUpdateRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->password,
]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
return back();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class PasswordUpdateRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProfileDeleteRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'password' => $this->currentPasswordRules(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\ProfileValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProfileUpdateRequest extends FormRequest
{
use ProfileValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return $this->profileRules($this->user()->id);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\InteractsWithTwoFactorState;
class TwoFactorAuthenticationRequest extends FormRequest
{
use InteractsWithTwoFactorState;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreReservationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->active === true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
'usage_location' => ['required', 'string', 'max:255'],
'starts_at' => ['required', 'date', 'after:now'],
'ends_at' => ['required', 'date', 'after:starts_at'],
'asset_ids' => ['required', 'array', 'min:1', 'max:10'],
'asset_ids.*' => ['required', 'integer', 'distinct', 'exists:snipe_assets,id'],
'terms_accepted' => ['accepted'],
];
}
}

83
app/Models/User.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role;
use App\Domain\Reservations\Models\Reservation;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string|null $remember_token
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'active', 'last_login_at'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasUuids, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
/** @return list<string> */
public function uniqueIds(): array
{
return ['uuid'];
}
/** @return BelongsToMany<Role, $this> */
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
/** @return HasMany<Reservation, $this> */
public function reservations(): HasMany
{
return $this->hasMany(Reservation::class, 'requester_id');
}
public function hasRole(RoleName|string $role): bool
{
$name = $role instanceof RoleName ? $role->value : $role;
return $this->roles()->where('name', $name)->exists();
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'active' => 'boolean',
'last_login_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureDefaults();
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'),
]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
'passwordRules' => Password::defaults()->toPasswordRulesString(),
]));
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]));
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/verify-email', [
'status' => $request->session()->get('status'),
]));
Fortify::registerView(fn () => Inertia::render('auth/register', [
'passwordRules' => Password::defaults()->toPasswordRulesString(),
]));
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(10)->by(
($request->input('credential.id') ?: $request->session()->getId()).'|'.$request->ip(),
);
});
}
}