From 59ad09fad27a30784518af691525093b5fd69c53 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 13 Apr 2026 10:19:14 +0200 Subject: [PATCH] feat(portal): auth persistence, shift visibility, profile page, and UI polish - Fix session persistence: add loading state to App.vue, hydrate portal store in router guards so page refresh preserves auth + event context - Fix shift visibility for festivals: query child event time slots so shifts on sub-events appear in the portal - Add profile page with editable personal info and password change - Add backend endpoints: PUT /portal/profile and PUT /portal/password - Fix registration form: make first_name/last_name editable for logged-in users - Restyle login page: remove Vuexy illustration, center form with Crewli branding - Improve dashboard StatusCard with action cards, icons, and upcoming shift count - Enhance shift cards with status border colors and availability progress bars Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Api/V1/Portal/PortalShiftController.php | 27 +- .../Controllers/Api/V1/PortalMeController.php | 56 +++ api/routes/api.php | 2 + .../Api/V1/Portal/PortalProfileTest.php | 196 +++++++++ .../Api/V1/Portal/PortalShiftClaimingTest.php | 83 ++++ apps/portal/src/App.vue | 20 +- .../src/components/portal/StatusCard.vue | 129 ++++-- .../src/composables/api/usePortalProfile.ts | 47 +++ .../src/pages/dashboard/claim-shifts.vue | 112 ++++-- apps/portal/src/pages/dashboard/index.vue | 14 +- apps/portal/src/pages/dashboard/my-shifts.vue | 35 +- apps/portal/src/pages/login.vue | 266 +++++------- apps/portal/src/pages/profile/index.vue | 377 +++++++++++++++++- .../portal/src/pages/register/[eventSlug].vue | 2 - apps/portal/src/plugins/1.router/guards.ts | 7 + apps/portal/src/stores/usePortalStore.ts | 20 + apps/portal/src/types/portal.ts | 6 + 17 files changed, 1145 insertions(+), 254 deletions(-) create mode 100644 api/tests/Feature/Api/V1/Portal/PortalProfileTest.php create mode 100644 apps/portal/src/composables/api/usePortalProfile.ts diff --git a/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php b/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php index 51d0c39c..f4fbfdcb 100644 --- a/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php +++ b/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php @@ -31,10 +31,12 @@ final class PortalShiftController extends Controller return $this->forbidden('Je moet eerst goedgekeurd zijn om diensten te claimen.'); } + $eventIds = $this->resolveEventIds($event); + $shifts = Shift::query() ->where('status', 'open') ->where('slots_open_for_claiming', '>', 0) - ->whereHas('timeSlot', fn ($q) => $q->where('event_id', $event->id)->where('person_type', 'VOLUNTEER')) + ->whereHas('timeSlot', fn ($q) => $q->whereIn('event_id', $eventIds)->where('person_type', 'VOLUNTEER')) ->with(['festivalSection', 'timeSlot', 'location']) ->withCount([ 'shiftAssignments as active_assignments_count' => fn ($q) => $q->whereNotIn('status', [ @@ -113,8 +115,10 @@ final class PortalShiftController extends Controller { $person = $this->resolvePerson($event); + $eventIds = $this->resolveEventIds($event); + $assignments = ShiftAssignment::where('person_id', $person->id) - ->whereHas('shift.timeSlot', fn ($q) => $q->where('event_id', $event->id)) + ->whereHas('shift.timeSlot', fn ($q) => $q->whereIn('event_id', $eventIds)) ->with(['shift.festivalSection', 'shift.timeSlot', 'shift.location']) ->get(); @@ -239,6 +243,25 @@ final class PortalShiftController extends Controller ->firstOrFail(); } + /** + * Get all event IDs relevant for shift queries. + * For festivals: includes parent + all child event IDs. + * For flat/sub-events: just the event's own ID. + * + * @return list + */ + private function resolveEventIds(Event $event): array + { + $ids = [$event->id]; + + if ($event->isFestival()) { + $childIds = $event->children()->pluck('id')->all(); + $ids = array_merge($ids, $childIds); + } + + return $ids; + } + private function mapClaimErrorMessage(string $message): string { if (str_contains($message, 'niet open')) { diff --git a/api/app/Http/Controllers/Api/V1/PortalMeController.php b/api/app/Http/Controllers/Api/V1/PortalMeController.php index 7c9908e1..a50674d1 100644 --- a/api/app/Http/Controllers/Api/V1/PortalMeController.php +++ b/api/app/Http/Controllers/Api/V1/PortalMeController.php @@ -12,6 +12,10 @@ use App\Models\Event; use App\Models\Person; use App\Models\TimeSlot; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Hash; +use Illuminate\Validation\Rules\Password; final class PortalMeController extends Controller { @@ -77,4 +81,56 @@ final class PortalMeController extends Controller return $this->success($data); } + + public function updateProfile(Request $request): JsonResponse + { + $validated = $request->validate([ + 'event_id' => ['required', 'ulid'], + 'first_name' => ['sometimes', 'string', 'max:255'], + 'last_name' => ['sometimes', 'string', 'max:255'], + 'phone' => ['sometimes', 'nullable', 'string', 'max:50'], + 'date_of_birth' => ['sometimes', 'nullable', 'date', 'before:today'], + 'remarks' => ['sometimes', 'nullable', 'string', 'max:5000'], + ]); + + $user = $request->user(); + + $event = Event::findOrFail($validated['event_id']); + + if ($event->isSubEvent()) { + $event = $event->parent; + } + + $person = Person::where('user_id', $user->id) + ->where('event_id', $event->id) + ->firstOrFail(); + + // Update user record (name fields) + $userFields = Arr::only($validated, ['first_name', 'last_name']); + if (!empty($userFields)) { + $user->update($userFields); + } + + // Update person record (phone, date_of_birth, remarks) + $personFields = Arr::only($validated, ['first_name', 'last_name', 'phone', 'date_of_birth', 'remarks']); + if (!empty($personFields)) { + $person->update($personFields); + } + + return $this->success(['message' => 'Profiel bijgewerkt.']); + } + + public function updatePassword(Request $request): JsonResponse + { + $validated = $request->validate([ + 'current_password' => ['required', 'string', 'current_password'], + 'password' => ['required', 'string', Password::min(8), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return $this->success(['message' => 'Wachtwoord gewijzigd.']); + } } diff --git a/api/routes/api.php b/api/routes/api.php index bdb6d912..6d6c2bd9 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -80,6 +80,8 @@ Route::middleware('auth:sanctum')->group(function () { // Portal (authenticated) Route::get('portal/me', [PortalMeController::class, 'index']); + Route::put('portal/profile', [PortalMeController::class, 'updateProfile']); + Route::put('portal/password', [PortalMeController::class, 'updatePassword']); Route::get('portal/events/{event}/available-shifts', [PortalShiftController::class, 'availableShifts']); Route::get('portal/events/{event}/my-shifts', [PortalShiftController::class, 'myShifts']); Route::post('portal/events/{event}/shifts/{shift}/claim', [PortalShiftController::class, 'claim']); diff --git a/api/tests/Feature/Api/V1/Portal/PortalProfileTest.php b/api/tests/Feature/Api/V1/Portal/PortalProfileTest.php new file mode 100644 index 00000000..2cea25e7 --- /dev/null +++ b/api/tests/Feature/Api/V1/Portal/PortalProfileTest.php @@ -0,0 +1,196 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->volunteer = User::factory()->create([ + 'first_name' => 'Jan', + 'last_name' => 'Jansen', + 'password' => Hash::make('old-password'), + ]); + $this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']); + + $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + $this->person = Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $crowdType->id, + 'user_id' => $this->volunteer->id, + 'first_name' => 'Jan', + 'last_name' => 'Jansen', + 'phone' => '0612345678', + ]); + } + + // ========================================================================= + // Profile update + // ========================================================================= + + public function test_update_profile_updates_user_and_person(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/profile', [ + 'event_id' => $this->event->id, + 'first_name' => 'Piet', + 'last_name' => 'Pietersen', + 'phone' => '0687654321', + 'date_of_birth' => '1990-05-15', + 'remarks' => 'Vegetarisch', + ]); + + $response->assertOk() + ->assertJsonPath('data.message', 'Profiel bijgewerkt.'); + + $this->volunteer->refresh(); + $this->assertEquals('Piet', $this->volunteer->first_name); + $this->assertEquals('Pietersen', $this->volunteer->last_name); + + $this->person->refresh(); + $this->assertEquals('Piet', $this->person->first_name); + $this->assertEquals('Pietersen', $this->person->last_name); + $this->assertEquals('0687654321', $this->person->phone); + $this->assertEquals('1990-05-15', $this->person->date_of_birth->toDateString()); + $this->assertEquals('Vegetarisch', $this->person->remarks); + } + + public function test_update_profile_partial_update(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/profile', [ + 'event_id' => $this->event->id, + 'phone' => '0699999999', + ]); + + $response->assertOk(); + + $this->person->refresh(); + $this->assertEquals('0699999999', $this->person->phone); + $this->assertEquals('Jan', $this->person->first_name); // unchanged + } + + public function test_update_profile_requires_event_id(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/profile', [ + 'first_name' => 'Piet', + ]); + + $response->assertUnprocessable(); + } + + public function test_update_profile_unauthenticated(): void + { + $response = $this->putJson('/api/v1/portal/profile', [ + 'event_id' => $this->event->id, + 'first_name' => 'Piet', + ]); + + $response->assertUnauthorized(); + } + + // ========================================================================= + // Password update + // ========================================================================= + + public function test_update_password(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/password', [ + 'current_password' => 'old-password', + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ]); + + $response->assertOk() + ->assertJsonPath('data.message', 'Wachtwoord gewijzigd.'); + + $this->volunteer->refresh(); + $this->assertTrue(Hash::check('new-secure-password', $this->volunteer->password)); + } + + public function test_update_password_wrong_current(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['current_password']); + } + + public function test_update_password_mismatch(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/password', [ + 'current_password' => 'old-password', + 'password' => 'new-secure-password', + 'password_confirmation' => 'different-password', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['password']); + } + + public function test_update_password_too_short(): void + { + Sanctum::actingAs($this->volunteer); + + $response = $this->putJson('/api/v1/portal/password', [ + 'current_password' => 'old-password', + 'password' => 'short', + 'password_confirmation' => 'short', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['password']); + } + + public function test_update_password_unauthenticated(): void + { + $response = $this->putJson('/api/v1/portal/password', [ + 'current_password' => 'old-password', + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ]); + + $response->assertUnauthorized(); + } +} diff --git a/api/tests/Feature/Api/V1/Portal/PortalShiftClaimingTest.php b/api/tests/Feature/Api/V1/Portal/PortalShiftClaimingTest.php index ba01f987..b3df23eb 100644 --- a/api/tests/Feature/Api/V1/Portal/PortalShiftClaimingTest.php +++ b/api/tests/Feature/Api/V1/Portal/PortalShiftClaimingTest.php @@ -254,6 +254,89 @@ class PortalShiftClaimingTest extends TestCase $this->assertEquals('Open Shift', $allShifts->first()['title']); } + public function test_available_shifts_includes_sub_event_shifts_for_festival(): void + { + // Create a festival parent with a sub-event + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + $subEvent = Event::factory()->subEvent($festival)->create(); + + // Person is registered at the festival level + $person = Person::factory()->approved()->create([ + 'event_id' => $festival->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $this->volunteer->id, + ]); + + // Shift lives on the sub-event + $subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]); + $subSlot = TimeSlot::factory()->create([ + 'event_id' => $subEvent->id, + 'person_type' => 'VOLUNTEER', + 'date' => now()->addMonth(), + ]); + $shift = Shift::factory()->open()->create([ + 'festival_section_id' => $subSection->id, + 'time_slot_id' => $subSlot->id, + 'slots_total' => 4, + 'slots_open_for_claiming' => 3, + 'title' => 'Sub-event Shift', + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->getJson("/api/v1/portal/events/{$festival->id}/available-shifts"); + + $response->assertOk(); + $allShifts = collect($response->json('data')) + ->flatMap(fn ($day) => collect($day['time_slots'])) + ->flatMap(fn ($ts) => $ts['shifts']); + + $this->assertCount(1, $allShifts); + $this->assertEquals('Sub-event Shift', $allShifts->first()['title']); + } + + public function test_my_shifts_includes_sub_event_assignments_for_festival(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + $subEvent = Event::factory()->subEvent($festival)->create(); + + $person = Person::factory()->approved()->create([ + 'event_id' => $festival->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $this->volunteer->id, + ]); + + $subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]); + $subSlot = TimeSlot::factory()->create([ + 'event_id' => $subEvent->id, + 'person_type' => 'VOLUNTEER', + 'date' => now()->addMonth(), + ]); + $shift = Shift::factory()->open()->create([ + 'festival_section_id' => $subSection->id, + 'time_slot_id' => $subSlot->id, + 'slots_total' => 4, + 'slots_open_for_claiming' => 3, + ]); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $subSlot->id, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->getJson("/api/v1/portal/events/{$festival->id}/my-shifts"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.upcoming')); + } + // ========================================================================= // My shifts // ========================================================================= diff --git a/apps/portal/src/App.vue b/apps/portal/src/App.vue index a944605d..c15af74d 100644 --- a/apps/portal/src/App.vue +++ b/apps/portal/src/App.vue @@ -4,16 +4,32 @@ import ScrollToTop from '@core/components/ScrollToTop.vue' import initCore from '@core/initCore' import { initConfigStore } from '@core/stores/config' import { hexToRgb } from '@core/utils/colorConverter' +import { useAuthStore } from '@/stores/useAuthStore' const { global } = useTheme() initCore() initConfigStore() + +const authStore = useAuthStore() + +// Validate stored token on app startup — must complete before rendering protected content +authStore.initialize() diff --git a/apps/portal/src/components/portal/StatusCard.vue b/apps/portal/src/components/portal/StatusCard.vue index e2df8242..d5f35d91 100644 --- a/apps/portal/src/components/portal/StatusCard.vue +++ b/apps/portal/src/components/portal/StatusCard.vue @@ -4,6 +4,8 @@ const props = defineProps<{ eventName: string registeredAt?: string | null nextShiftSummary?: string | null + upcomingCount?: number + availableCount?: number | null }>() const registeredLabel = computed(() => { @@ -88,6 +90,7 @@ const registeredLabel = computed(() => { + { > -
- Mijn Diensten -
-
- Rooster bekijken -
+ + +
+ Mijn Diensten +
+
+ Rooster bekijken +
+
{ > -
- Diensten claimen -
-
- Schrijf je in voor diensten -
+ + +
+ Diensten Claimen +
+
+ Schrijf je in +
+
{ > -
- Profiel -
-
- Gegevens bekijken -
+ + +
+ Mijn Profiel +
+
+ Gegevens bekijken +
+
+
- Komende shift + Komende dienst

{ v-else class="text-body-2 text-medium-emphasis mb-0" > - Er is nog geen shift ingepland. Je coördinator houdt je op de hoogte. + Nog geen diensten ingepland. + + Diensten claimen +

+ + +
+
+ + Diensten ingepland: {{ upcomingCount }} +
+ + + Beschikbare diensten bekijken + +
+ + diff --git a/apps/portal/src/composables/api/usePortalProfile.ts b/apps/portal/src/composables/api/usePortalProfile.ts new file mode 100644 index 00000000..8fd7fe18 --- /dev/null +++ b/apps/portal/src/composables/api/usePortalProfile.ts @@ -0,0 +1,47 @@ +import { useMutation } from '@tanstack/vue-query' +import { apiClient } from '@/lib/axios' + +interface ApiResponse { + data: T +} + +export interface ProfileUpdatePayload { + event_id: string + first_name?: string + last_name?: string + phone?: string | null + date_of_birth?: string | null + remarks?: string | null +} + +export interface PasswordUpdatePayload { + current_password: string + password: string + password_confirmation: string +} + +export function useUpdateProfile() { + return useMutation({ + mutationFn: async (payload: ProfileUpdatePayload) => { + const { data } = await apiClient.put>( + '/portal/profile', + payload, + ) + + return data.data + }, + }) +} + +export function useUpdatePassword() { + return useMutation({ + mutationFn: async (payload: PasswordUpdatePayload) => { + const { data } = await apiClient.put>( + '/portal/password', + payload, + ) + + return data.data + }, + }) +} diff --git a/apps/portal/src/pages/dashboard/claim-shifts.vue b/apps/portal/src/pages/dashboard/claim-shifts.vue index a40013f1..876530a6 100644 --- a/apps/portal/src/pages/dashboard/claim-shifts.vue +++ b/apps/portal/src/pages/dashboard/claim-shifts.vue @@ -66,11 +66,7 @@ function availabilityColor(slotsAvailable: number): string { return 'warning' } -onMounted(async () => { - if (!portal.activeEventId) { - await portal.hydrateAfterAuth() - } -}) +// Portal hydration now happens automatically in the router guard + + diff --git a/apps/portal/src/pages/dashboard/index.vue b/apps/portal/src/pages/dashboard/index.vue index e79c7bb0..fcd919f8 100644 --- a/apps/portal/src/pages/dashboard/index.vue +++ b/apps/portal/src/pages/dashboard/index.vue @@ -1,6 +1,7 @@ diff --git a/apps/portal/src/pages/dashboard/my-shifts.vue b/apps/portal/src/pages/dashboard/my-shifts.vue index e6b7fdfb..ee29eca9 100644 --- a/apps/portal/src/pages/dashboard/my-shifts.vue +++ b/apps/portal/src/pages/dashboard/my-shifts.vue @@ -58,11 +58,7 @@ async function confirmCancel() { } } -onMounted(async () => { - if (!portal.activeEventId) { - await portal.hydrateAfterAuth() - } -}) +// Portal hydration now happens automatically in the router guard