diff --git a/api/.env.example b/api/.env.example index 005852be..27ad2637 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,7 +1,8 @@ APP_NAME="Crewli" APP_ENV=local APP_KEY= -APP_DEBUG=true +# Set to true only in local development +APP_DEBUG=false # Local API origin (no path suffix). Production: https://api.crewli.app APP_URL=http://localhost:8000 diff --git a/api/app/Http/Controllers/Api/V1/LoginController.php b/api/app/Http/Controllers/Api/V1/LoginController.php index 226b4696..2a9ce889 100644 --- a/api/app/Http/Controllers/Api/V1/LoginController.php +++ b/api/app/Http/Controllers/Api/V1/LoginController.php @@ -9,12 +9,19 @@ use App\Http\Requests\Api\V1\LoginRequest; use App\Http\Resources\Api\V1\UserResource; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; final class LoginController extends Controller { public function __invoke(LoginRequest $request): JsonResponse { if (!Auth::attempt($request->only('email', 'password'))) { + Log::warning('Failed login attempt', [ + 'email' => $request->validated('email'), + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + return $this->unauthorized('Invalid credentials'); } diff --git a/api/app/Http/Controllers/Api/V1/PasswordResetController.php b/api/app/Http/Controllers/Api/V1/PasswordResetController.php index 9af0d238..a852f83e 100644 --- a/api/app/Http/Controllers/Api/V1/PasswordResetController.php +++ b/api/app/Http/Controllers/Api/V1/PasswordResetController.php @@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; +use Illuminate\Validation\Rules\Password as PasswordRule; final class PasswordResetController extends Controller { @@ -29,13 +30,14 @@ final class PasswordResetController extends Controller $request->validate([ 'token' => 'required', 'email' => 'required|email', - 'password' => 'required|min:8|confirmed', + 'password' => ['required', 'confirmed', PasswordRule::min(8)->mixedCase()->numbers()], ]); $status = Password::reset( $request->only('email', 'password', 'password_confirmation', 'token'), function ($user, $password) { $user->forceFill(['password' => Hash::make($password)])->save(); + $user->tokens()->delete(); } ); diff --git a/api/app/Http/Middleware/SecurityHeaders.php b/api/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 00000000..d9262f48 --- /dev/null +++ b/api/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,29 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'DENY'); + $response->headers->set('X-XSS-Protection', '0'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + if ($request->isSecure() || app()->environment('production')) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php b/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php index c9045bf6..318763d6 100644 --- a/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php +++ b/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php @@ -7,6 +7,7 @@ namespace App\Http\Requests\Api\V1; use App\Models\User; use App\Models\UserInvitation; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rules\Password; final class AcceptInvitationRequest extends FormRequest { @@ -24,7 +25,7 @@ final class AcceptInvitationRequest extends FormRequest return [ 'first_name' => [$userExists ? 'nullable' : 'required', 'string', 'max:255'], 'last_name' => [$userExists ? 'nullable' : 'required', 'string', 'max:255'], - 'password' => [$userExists ? 'nullable' : 'required', 'string', 'min:8', 'confirmed'], + 'password' => [$userExists ? 'nullable' : 'required', 'string', 'confirmed', Password::min(8)->mixedCase()->numbers()], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php b/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php index 73842ea6..3180eb9c 100644 --- a/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php @@ -23,7 +23,6 @@ final class UpdateOrganisationRequest extends FormRequest 'sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/', Rule::unique('organisations', 'slug')->ignore($this->route('organisation')), ], - 'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'], 'settings' => ['sometimes', 'array'], 'email_logo_url' => ['nullable', 'url', 'max:500'], 'email_primary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], diff --git a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php index fde725ba..48da32ff 100644 --- a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php +++ b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rules\Password; final class VolunteerRegistrationRequest extends FormRequest { @@ -60,7 +61,7 @@ final class VolunteerRegistrationRequest extends FormRequest // Password required for unauthenticated registrations if ($user === null) { - $rules['password'] = ['required', 'string', 'min:8']; + $rules['password'] = ['required', 'string', Password::min(8)->mixedCase()->numbers()]; $rules['password_confirmation'] = ['nullable', 'same:password']; } diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index b793ceee..99d9e469 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -24,7 +24,6 @@ final class Person extends Model protected $table = 'persons'; protected $fillable = [ - 'user_id', 'event_id', 'crowd_type_id', 'company_id', diff --git a/api/app/Models/ShiftAssignment.php b/api/app/Models/ShiftAssignment.php index 563c55b8..5acc44db 100644 --- a/api/app/Models/ShiftAssignment.php +++ b/api/app/Models/ShiftAssignment.php @@ -24,19 +24,9 @@ final class ShiftAssignment extends Model 'person_id', 'time_slot_id', 'status', - 'auto_approved', - 'assigned_by', - 'assigned_at', - 'approved_by', - 'approved_at', 'rejection_reason', - 'cancelled_by', - 'cancellation_source', - 'cancelled_at', 'hours_expected', 'hours_completed', - 'checked_in_at', - 'checked_out_at', ]; protected function casts(): array diff --git a/api/app/Models/UserInvitation.php b/api/app/Models/UserInvitation.php index 1a19336b..5b2b239f 100644 --- a/api/app/Models/UserInvitation.php +++ b/api/app/Models/UserInvitation.php @@ -17,13 +17,7 @@ final class UserInvitation extends Model protected $fillable = [ 'email', - 'invited_by_user_id', - 'organisation_id', 'event_id', - 'role', - 'token', - 'status', - 'expires_at', ]; protected function casts(): array @@ -60,12 +54,14 @@ final class UserInvitation extends Model public function markAsAccepted(): void { - $this->update(['status' => 'accepted']); + $this->status = 'accepted'; + $this->save(); } public function markAsExpired(): void { - $this->update(['status' => 'expired']); + $this->status = 'expired'; + $this->save(); } public function scopePending(Builder $query): Builder diff --git a/api/app/Services/InvitationService.php b/api/app/Services/InvitationService.php index c0a1296c..8325d4ae 100644 --- a/api/app/Services/InvitationService.php +++ b/api/app/Services/InvitationService.php @@ -37,15 +37,14 @@ final class InvitationService ]); } - $invitation = UserInvitation::create([ - 'email' => $email, - 'invited_by_user_id' => $invitedBy->id, - 'organisation_id' => $org->id, - 'role' => $role, - 'token' => strtolower((string) Str::ulid()), - 'status' => 'pending', - 'expires_at' => now()->addDays(7), - ]); + $invitation = new UserInvitation(['email' => $email]); + $invitation->invited_by_user_id = $invitedBy->id; + $invitation->organisation_id = $org->id; + $invitation->role = $role; + $invitation->token = strtolower((string) Str::ulid()); + $invitation->status = 'pending'; + $invitation->expires_at = now()->addDays(7); + $invitation->save(); Mail::to($email)->queue(new InvitationMail($invitation)); diff --git a/api/app/Services/PersonIdentityService.php b/api/app/Services/PersonIdentityService.php index 9d798242..776c4e11 100644 --- a/api/app/Services/PersonIdentityService.php +++ b/api/app/Services/PersonIdentityService.php @@ -181,9 +181,9 @@ final class PersonIdentityService 'resolved_at' => now(), ]); - $person->update([ - 'user_id' => $match->matched_user_id, - ]); + // Set user_id explicitly (not mass-assignable) + $person->user_id = $match->matched_user_id; + $person->save(); }); activity('identity') diff --git a/api/app/Services/ShiftAssignmentService.php b/api/app/Services/ShiftAssignmentService.php index cfeb38e3..84557ffd 100644 --- a/api/app/Services/ShiftAssignmentService.php +++ b/api/app/Services/ShiftAssignmentService.php @@ -36,11 +36,13 @@ final class ShiftAssignmentService 'person_id' => $person->id, 'time_slot_id' => $shift->time_slot_id, 'status' => $status, - 'auto_approved' => $autoApprove, - 'assigned_at' => now(), - 'approved_at' => $autoApprove ? now() : null, ]); + $assignment->auto_approved = $autoApprove; + $assignment->assigned_at = now(); + $assignment->approved_at = $autoApprove ? now() : null; + $assignment->save(); + $this->updateShiftStatusIfFull($shift); activity('shift_assignment') @@ -85,17 +87,16 @@ final class ShiftAssignmentService if ($existing) { $previousStatus = $existing->status->value; - $existing->update([ - 'status' => ShiftAssignmentStatus::APPROVED, - 'assigned_by' => $assignedBy->id, - 'assigned_at' => now(), - 'approved_by' => $assignedBy->id, - 'approved_at' => now(), - 'rejection_reason' => null, - 'cancelled_by' => null, - 'cancellation_source' => null, - 'cancelled_at' => null, - ]); + $existing->status = ShiftAssignmentStatus::APPROVED; + $existing->assigned_by = $assignedBy->id; + $existing->assigned_at = now(); + $existing->approved_by = $assignedBy->id; + $existing->approved_at = now(); + $existing->rejection_reason = null; + $existing->cancelled_by = null; + $existing->cancellation_source = null; + $existing->cancelled_at = null; + $existing->save(); activity('shift_assignment') ->causedBy($assignedBy) @@ -133,13 +134,15 @@ final class ShiftAssignmentService 'person_id' => $person->id, 'time_slot_id' => $shift->time_slot_id, 'status' => ShiftAssignmentStatus::APPROVED, - 'auto_approved' => false, - 'assigned_by' => $assignedBy->id, - 'assigned_at' => now(), - 'approved_by' => $assignedBy->id, - 'approved_at' => now(), ]); + $assignment->auto_approved = false; + $assignment->assigned_by = $assignedBy->id; + $assignment->assigned_at = now(); + $assignment->approved_by = $assignedBy->id; + $assignment->approved_at = now(); + $assignment->save(); + $this->updateShiftStatusIfFull($shift); activity('shift_assignment') @@ -177,11 +180,10 @@ final class ShiftAssignmentService $oldStatus = $assignment->status; - $assignment->update([ - 'status' => ShiftAssignmentStatus::APPROVED, - 'approved_by' => $approvedBy->id, - 'approved_at' => now(), - ]); + $assignment->status = ShiftAssignmentStatus::APPROVED; + $assignment->approved_by = $approvedBy->id; + $assignment->approved_at = now(); + $assignment->save(); $this->updateShiftStatusIfFull($shift); @@ -207,10 +209,9 @@ final class ShiftAssignmentService $oldStatus = $assignment->status; - $assignment->update([ - 'status' => ShiftAssignmentStatus::REJECTED, - 'rejection_reason' => $reason, - ]); + $assignment->status = ShiftAssignmentStatus::REJECTED; + $assignment->rejection_reason = $reason; + $assignment->save(); activity('shift_assignment') ->causedBy($rejectedBy) @@ -239,12 +240,11 @@ final class ShiftAssignmentService $wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED; $oldStatus = $assignment->status; - $assignment->update([ - 'status' => ShiftAssignmentStatus::CANCELLED, - 'cancelled_by' => $cancelledBy->id, - 'cancellation_source' => $source, - 'cancelled_at' => now(), - ]); + $assignment->status = ShiftAssignmentStatus::CANCELLED; + $assignment->cancelled_by = $cancelledBy->id; + $assignment->cancellation_source = $source; + $assignment->cancelled_at = now(); + $assignment->save(); if ($wasApproved) { $this->updateShiftStatusAfterCancellation($assignment->shift); @@ -292,11 +292,10 @@ final class ShiftAssignmentService ]; } - $assignment->update([ - 'status' => ShiftAssignmentStatus::APPROVED, - 'approved_by' => $approvedBy->id, - 'approved_at' => now(), - ]); + $assignment->status = ShiftAssignmentStatus::APPROVED; + $assignment->approved_by = $approvedBy->id; + $assignment->approved_at = now(); + $assignment->save(); $this->updateShiftStatusIfFull($shift); diff --git a/api/app/Services/VolunteerRegistrationService.php b/api/app/Services/VolunteerRegistrationService.php index 19e139bb..4839d905 100644 --- a/api/app/Services/VolunteerRegistrationService.php +++ b/api/app/Services/VolunteerRegistrationService.php @@ -57,7 +57,6 @@ final class VolunteerRegistrationService 'email' => $email, ], [ - 'user_id' => $user->id, 'crowd_type_id' => $volunteerCrowdType->id, 'first_name' => $validated['first_name'] ?? $user->first_name, 'last_name' => $validated['last_name'] ?? $user->last_name, @@ -75,6 +74,10 @@ final class VolunteerRegistrationService ] ); + // Set user_id explicitly (not mass-assignable) + $person->user_id = $user->id; + $person->save(); + $this->syncAvailabilities($person, $festivalEvent, $validated['availabilities'] ?? []); if (!empty($validated['field_values'])) { diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 23e69033..e28fbad2 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; use Illuminate\Database\QueryException; use Illuminate\Foundation\Application; @@ -24,6 +25,8 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { // API uses token-based auth, no CSRF needed + $middleware->append(\App\Http\Middleware\SecurityHeaders::class); + $middleware->alias([ 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, ]); @@ -60,6 +63,20 @@ return Application::configure(basePath: dirname(__DIR__)) } }); + // Authorization failures → log with user context + $exceptions->render(function (AuthorizationException $e, Request $request) { + if ($request->expectsJson() || $request->is('api/*')) { + Log::warning('Authorization denied', [ + 'user_id' => auth()->id(), + 'ip' => $request->ip(), + 'path' => $request->path(), + 'method' => $request->method(), + ]); + } + + return null; // Let Laravel handle the 403 response normally + }); + // All other unhandled exceptions → 500 // (ValidationException, AuthenticationException, and HttpException are handled by Laravel) $exceptions->render(function (Throwable $e, Request $request) { diff --git a/api/config/sanctum.php b/api/config/sanctum.php index 6794cdac..e8c950aa 100644 --- a/api/config/sanctum.php +++ b/api/config/sanctum.php @@ -49,7 +49,7 @@ return [ | */ - 'expiration' => null, + 'expiration' => 60 * 24 * 7, // 7 days in minutes /* |-------------------------------------------------------------------------- diff --git a/api/database/factories/PersonFactory.php b/api/database/factories/PersonFactory.php index e552584d..0678d3df 100644 --- a/api/database/factories/PersonFactory.php +++ b/api/database/factories/PersonFactory.php @@ -29,6 +29,29 @@ final class PersonFactory extends Factory ]; } + /** + * Override create to handle user_id which is not mass-assignable. + * + * @param array $attributes + */ + public function create($attributes = [], ?\Illuminate\Database\Eloquent\Model $parent = null): Person|\Illuminate\Database\Eloquent\Collection + { + $userId = $attributes['user_id'] ?? null; + unset($attributes['user_id']); + + $result = parent::create($attributes, $parent); + + if ($userId !== null) { + $models = $result instanceof Person ? collect([$result]) : $result; + $models->each(function (Person $person) use ($userId): void { + $person->user_id = $userId; + $person->save(); + }); + } + + return $result; + } + public function approved(): static { return $this->state(fn () => ['status' => 'approved']); diff --git a/api/database/factories/ShiftAssignmentFactory.php b/api/database/factories/ShiftAssignmentFactory.php index a4b7e0ab..b8b3aa95 100644 --- a/api/database/factories/ShiftAssignmentFactory.php +++ b/api/database/factories/ShiftAssignmentFactory.php @@ -22,25 +22,34 @@ final class ShiftAssignmentFactory extends Factory 'person_id' => Person::factory(), 'time_slot_id' => TimeSlot::factory(), 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, - 'auto_approved' => false, - 'assigned_at' => now(), ]; } + public function configure(): static + { + return $this->afterCreating(function (ShiftAssignment $assignment): void { + $assignment->auto_approved = false; + $assignment->assigned_at = now(); + $assignment->save(); + }); + } + public function approved(): static { - return $this->state(fn () => [ - 'status' => ShiftAssignmentStatus::APPROVED, - 'approved_at' => now(), - ]); + return $this->afterCreating(function (ShiftAssignment $assignment): void { + $assignment->status = ShiftAssignmentStatus::APPROVED; + $assignment->approved_at = now(); + $assignment->save(); + }); } public function autoApproved(): static { - return $this->state(fn () => [ - 'status' => ShiftAssignmentStatus::APPROVED, - 'auto_approved' => true, - 'approved_at' => now(), - ]); + return $this->afterCreating(function (ShiftAssignment $assignment): void { + $assignment->status = ShiftAssignmentStatus::APPROVED; + $assignment->auto_approved = true; + $assignment->approved_at = now(); + $assignment->save(); + }); } } diff --git a/api/database/factories/UserInvitationFactory.php b/api/database/factories/UserInvitationFactory.php index 0ff2b114..11a7a523 100644 --- a/api/database/factories/UserInvitationFactory.php +++ b/api/database/factories/UserInvitationFactory.php @@ -18,13 +18,19 @@ final class UserInvitationFactory extends Factory { return [ 'email' => fake()->unique()->safeEmail(), - 'invited_by_user_id' => User::factory(), - 'organisation_id' => Organisation::factory(), 'event_id' => null, - 'role' => 'org_member', - 'token' => strtolower((string) Str::ulid()), - 'status' => 'pending', - 'expires_at' => now()->addDays(7), ]; } + + public function configure(): static + { + return $this->afterMaking(function (UserInvitation $invitation): void { + $invitation->invited_by_user_id ??= User::factory()->create()->id; + $invitation->organisation_id ??= Organisation::factory()->create()->id; + $invitation->role ??= 'org_member'; + $invitation->token ??= strtolower((string) Str::ulid()); + $invitation->status ??= 'pending'; + $invitation->expires_at ??= now()->addDays(7); + }); + } } diff --git a/api/routes/api.php b/api/routes/api.php index 6d6c2bd9..9c0b55e8 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -55,11 +55,11 @@ Route::get('/', fn () => response()->json([ ])); // Public auth routes -Route::post('auth/login', LoginController::class); +Route::post('auth/login', LoginController::class)->middleware('throttle:5,1'); // Public invitation routes (no auth required) -Route::get('invitations/{token}', [InvitationController::class, 'show']); -Route::post('invitations/{token}/accept', [InvitationController::class, 'accept']); +Route::get('invitations/{token}', [InvitationController::class, 'show'])->middleware('throttle:10,1'); +Route::post('invitations/{token}/accept', [InvitationController::class, 'accept'])->middleware('throttle:10,1'); // Password reset Route::post('auth/forgot-password', [PasswordResetController::class, 'sendResetLink']) @@ -69,8 +69,8 @@ Route::post('auth/reset-password', [PasswordResetController::class, 'resetPasswo // Public portal routes Route::get('public/events/{slug}/registration-data', PublicRegistrationDataController::class); Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1'); -Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class); -Route::post('portal/token-auth', [PortalTokenController::class, 'auth']); +Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class)->middleware('throttle:5,1'); +Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1'); // Protected routes Route::middleware('auth:sanctum')->group(function () { diff --git a/api/routes/web.php b/api/routes/web.php index db4dd713..0b56d2d1 100644 --- a/api/routes/web.php +++ b/api/routes/web.php @@ -5,7 +5,9 @@ use App\Mail\RegistrationApprovedMail; use App\Mail\RegistrationConfirmationMail; use App\Mail\RegistrationRejectedMail; use App\Models\Event; +use App\Models\Organisation; use App\Models\Person; +use App\Models\User; use App\Models\UserInvitation; use Illuminate\Support\Facades\Route; @@ -23,26 +25,19 @@ if (app()->environment('local', 'staging')) { } if ($type === 'invitation') { - $invitation = UserInvitation::first(); - - if (! $invitation) { - return response('No UserInvitation found in the database. Create one first to preview this mail.', 422); - } + $organisation = Organisation::factory()->make(); + $invitation = UserInvitation::factory()->make(); + $invitation->setRelation('organisation', $organisation); + $invitation->setRelation('invitedBy', User::factory()->make()); + $invitation->token ??= 'preview-token'; + $invitation->role ??= 'org_member'; + $invitation->expires_at ??= now()->addDays(7); return new InvitationMail($invitation); } - $event = Event::first(); - $person = Person::first(); - - if (! $event || ! $person) { - $missing = collect([ - 'Event' => $event, - 'Person' => $person, - ])->filter(fn ($v) => $v === null)->keys()->implode(', '); - - return response("No test data found. Missing: {$missing}. Seed the database first.", 422); - } + $event = Event::factory()->make(); + $person = Person::factory()->make(); return match ($type) { 'registration-confirmation' => new RegistrationConfirmationMail($person, $event), diff --git a/api/tests/Feature/Api/V1/PasswordResetTest.php b/api/tests/Feature/Api/V1/PasswordResetTest.php index b20c1f72..ebbd1ac4 100644 --- a/api/tests/Feature/Api/V1/PasswordResetTest.php +++ b/api/tests/Feature/Api/V1/PasswordResetTest.php @@ -85,8 +85,8 @@ class PasswordResetTest extends TestCase $response = $this->postJson('/api/v1/auth/reset-password', [ 'token' => $token, 'email' => 'jan@voorbeeld.nl', - 'password' => 'nieuwwachtwoord123', - 'password_confirmation' => 'nieuwwachtwoord123', + 'password' => 'NieuwWachtwoord1', + 'password_confirmation' => 'NieuwWachtwoord1', ]); $response->assertOk(); @@ -94,7 +94,7 @@ class PasswordResetTest extends TestCase // Verify password was actually changed $user->refresh(); - $this->assertTrue(Hash::check('nieuwwachtwoord123', $user->password)); + $this->assertTrue(Hash::check('NieuwWachtwoord1', $user->password)); } public function test_reset_password_with_invalid_token_returns_422(): void @@ -104,8 +104,8 @@ class PasswordResetTest extends TestCase $response = $this->postJson('/api/v1/auth/reset-password', [ 'token' => 'invalid-token-here', 'email' => 'jan@voorbeeld.nl', - 'password' => 'nieuwwachtwoord123', - 'password_confirmation' => 'nieuwwachtwoord123', + 'password' => 'NieuwWachtwoord1', + 'password_confirmation' => 'NieuwWachtwoord1', ]); $response->assertStatus(422); @@ -120,7 +120,7 @@ class PasswordResetTest extends TestCase $response = $this->postJson('/api/v1/auth/reset-password', [ 'token' => $token, 'email' => 'jan@voorbeeld.nl', - 'password' => 'nieuwwachtwoord123', + 'password' => 'NieuwWachtwoord1', ]); $response->assertStatus(422); diff --git a/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php b/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php index 2734499d..54738269 100644 --- a/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php +++ b/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php @@ -144,7 +144,8 @@ class PortalMeUpcomingShiftTest extends TestCase ]); $pendingUser = User::factory()->create(['email' => $pendingPerson->email]); - $pendingPerson->update(['user_id' => $pendingUser->id]); + $pendingPerson->user_id = $pendingUser->id; + $pendingPerson->save(); $futureDate = now()->addDays(7)->toDateString(); diff --git a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php index 266b445e..c2277987 100644 --- a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php +++ b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php @@ -219,7 +219,7 @@ class RegistrationSettingsTest extends TestCase 'first_name' => 'Test', 'last_name' => 'Vrijwilliger', 'email' => 'test-section-pref@example.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'section_preferences' => [ ['festival_section_id' => $section->id, 'priority' => 1], ], diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php index 22578e68..873acf79 100644 --- a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php +++ b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php @@ -63,7 +63,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'phone' => '+31612345678', 'tshirt_size' => 'L', 'motivation' => 'Ik wil graag helpen bij dit festival!', @@ -89,7 +89,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Sophie', 'last_name' => 'Bakker', 'email' => 'sophie@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(201); @@ -117,7 +117,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Pieter', 'last_name' => 'Jansen', 'email' => 'pieter@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(201); @@ -137,7 +137,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Fleur', 'last_name' => 'Vermeer', 'email' => 'fleur@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'availabilities' => [ ['time_slot_id' => $this->timeSlot->id, 'preference_level' => 4], ['time_slot_id' => $timeSlot2->id, 'preference_level' => 2], @@ -168,7 +168,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Daan', 'last_name' => 'Mulder', 'email' => 'daan@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'tshirt_size' => 'XL', 'motivation' => 'Ik vind festivals geweldig.', 'section_preferences' => [ @@ -199,7 +199,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Mila', 'last_name' => 'de Boer', 'email' => 'mila@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'date_of_birth' => '1998-05-12', ]); @@ -217,7 +217,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Sem', 'last_name' => 'van Beek', 'email' => 'sem@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(201); @@ -232,7 +232,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Tijn', 'last_name' => 'Kuiper', 'email' => 'tijn@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'date_of_birth' => now()->addDay()->format('Y-m-d'), ]); @@ -248,14 +248,14 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Anna', 'last_name' => 'Smit', 'email' => 'anna@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Anna', 'last_name' => 'Smit', 'email' => 'anna@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(422); @@ -276,7 +276,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Herkan', 'last_name' => 'Poging', 'email' => 'herkan@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(200); @@ -298,7 +298,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Test', 'last_name' => 'Persoon', 'email' => 'test@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(422); @@ -310,7 +310,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Bas', 'last_name' => 'van Dijk', 'email' => 'bas@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'availabilities' => [ ['time_slot_id' => '01JNONEXISTENT00000000000', 'preference_level' => 3], ], @@ -486,7 +486,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Noor', 'last_name' => 'Janssen', 'email' => 'noor@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'field_values' => [ $selectField->slug => 'L', $textField->slug => 'Ik ben een ervaren vrijwilliger', @@ -528,7 +528,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Rick', 'last_name' => 'Peters', 'email' => 'rick@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'section_preferences' => [ ['festival_section_id' => $section1->id, 'priority' => 1], ['festival_section_id' => $section2->id, 'priority' => 2], @@ -564,7 +564,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Femke', 'last_name' => 'de Jong', 'email' => 'femke@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', 'field_values' => [ $multiselectField->slug => ['Vegetarisch', 'Glutenvrij'], ], @@ -596,7 +596,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Nieuwe', 'last_name' => 'Vrijwilliger', 'email' => 'nieuw@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(201); @@ -624,14 +624,14 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Terug', 'last_name' => 'Keerder', 'email' => 'terug@voorbeeld.nl', - 'password' => Hash::make('bestaandwachtwoord'), + 'password' => Hash::make('BestaandWw1'), ]); $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Terug', 'last_name' => 'Keerder', 'email' => 'terug@voorbeeld.nl', - 'password' => 'bestaandwachtwoord', + 'password' => 'BestaandWw1', ]); $response->assertStatus(201); @@ -672,7 +672,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Mail', 'last_name' => 'Test', 'email' => 'mailtest@voorbeeld.nl', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); Mail::assertQueued(RegistrationConfirmationMail::class, function ($mail) { @@ -688,7 +688,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Hoofdletter', 'last_name' => 'Email', 'email' => 'HOOFDLETTER@VOORBEELD.NL', - 'password' => 'wachtwoord123', + 'password' => 'Wachtwoord1', ]); $response->assertStatus(201); diff --git a/api/tests/Feature/Invitation/InvitationTest.php b/api/tests/Feature/Invitation/InvitationTest.php index d33cbf84..9fc5fbf3 100644 --- a/api/tests/Feature/Invitation/InvitationTest.php +++ b/api/tests/Feature/Invitation/InvitationTest.php @@ -154,8 +154,8 @@ class InvitationTest extends TestCase $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ 'first_name' => 'New', 'last_name' => 'User', - 'password' => 'password123', - 'password_confirmation' => 'password123', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', ]); $response->assertOk(); @@ -207,8 +207,8 @@ class InvitationTest extends TestCase $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ 'first_name' => 'Test', 'last_name' => 'User', - 'password' => 'password123', - 'password_confirmation' => 'password123', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', ]); $response->assertUnprocessable(); @@ -226,8 +226,8 @@ class InvitationTest extends TestCase $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ 'first_name' => 'Test', 'last_name' => 'User', - 'password' => 'password123', - 'password_confirmation' => 'password123', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', ]); $response->assertUnprocessable(); diff --git a/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php b/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php index 223245c8..30e4cc9e 100644 --- a/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php +++ b/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php @@ -243,7 +243,8 @@ class PersonFieldValueTest extends TestCase { $linkedUser = User::factory()->create(); - $this->person->update(['user_id' => $linkedUser->id]); + $this->person->user_id = $linkedUser->id; + $this->person->save(); $tag = PersonTag::factory()->create([ 'organisation_id' => $this->organisation->id, diff --git a/apps/admin/src/pages/login.vue b/apps/admin/src/pages/login.vue index 895043ed..2582946d 100644 --- a/apps/admin/src/pages/login.vue +++ b/apps/admin/src/pages/login.vue @@ -94,7 +94,8 @@ const login = async () => { // Redirect to `to` query if exist or redirect to index route await nextTick() - router.replace(route.query.to ? String(route.query.to) : '/') + const rawTo = route.query.to ? String(route.query.to) : '' + router.replace(rawTo.startsWith('/') ? rawTo : '/') } catch (err) { console.error(err) diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue index 87e3afd2..fd96cae0 100644 --- a/apps/app/src/pages/login.vue +++ b/apps/app/src/pages/login.vue @@ -52,7 +52,8 @@ function handleLogin() { { email: form.value.email, password: form.value.password }, { onSuccess: () => { - const redirectTo = route.query.to ? String(route.query.to) : '/dashboard' + const rawTo = route.query.to ? String(route.query.to) : '' + const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard' router.replace(redirectTo) }, diff --git a/apps/portal/src/pages/login.vue b/apps/portal/src/pages/login.vue index 7557b878..9f1748b7 100644 --- a/apps/portal/src/pages/login.vue +++ b/apps/portal/src/pages/login.vue @@ -58,7 +58,8 @@ async function onSubmit(): Promise { // Navigate after login — outside try/catch so navigation errors // (e.g. stale dynamic imports) don't mask a successful login. - let redirect = typeof route.query.to === 'string' ? route.query.to : '' + const rawRedirect = typeof route.query.to === 'string' ? route.query.to : '' + let redirect = rawRedirect.startsWith('/') ? rawRedirect : '' // Smart redirect based on number of events if (!redirect) {