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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 10:19:14 +02:00
parent 838bee4d60
commit 59ad09fad2
17 changed files with 1145 additions and 254 deletions

View File

@@ -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<string>
*/
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')) {

View File

@@ -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.']);
}
}

View File

@@ -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']);

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Portal;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class PortalProfileTest extends TestCase
{
use RefreshDatabase;
private User $volunteer;
private Organisation $organisation;
private Event $event;
private Person $person;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}

View File

@@ -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
// =========================================================================

View File

@@ -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()
</script>
<template>
<VApp :style="`--v-global-theme-primary: ${hexToRgb(global.current.value.colors.primary)}`">
<RouterView />
<ScrollToTop />
<!-- Show loading state while validating auth token -->
<template v-if="!authStore.isInitialized">
<div class="d-flex align-center justify-center" style="min-height: 100vh;">
<VProgressCircular indeterminate color="primary" size="48" />
</div>
</template>
<!-- Only render app shell after auth is resolved -->
<template v-else>
<RouterView />
<ScrollToTop />
</template>
</VApp>
</template>

View File

@@ -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(() => {
</div>
</div>
<!-- Quick action cards -->
<VRow class="mb-6">
<VCol
cols="12"
@@ -95,15 +98,23 @@ const registeredLabel = computed(() => {
>
<VCard
:to="{ name: 'portal-my-shifts' }"
variant="outlined"
class="pa-4 h-100 text-decoration-none"
class="h-100 text-decoration-none portal-action-card"
elevation="1"
>
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Mijn Diensten
</div>
<div class="text-body-2">
Rooster bekijken
</div>
<VCardText class="d-flex flex-column align-center text-center pa-4">
<VIcon
icon="tabler-calendar-check"
size="28"
color="primary"
class="mb-2"
/>
<div class="text-subtitle-2 font-weight-bold mb-1">
Mijn Diensten
</div>
<div class="text-caption text-medium-emphasis">
Rooster bekijken
</div>
</VCardText>
</VCard>
</VCol>
<VCol
@@ -112,15 +123,23 @@ const registeredLabel = computed(() => {
>
<VCard
:to="{ name: 'portal-claim-shifts' }"
variant="outlined"
class="pa-4 h-100 text-decoration-none"
class="h-100 text-decoration-none portal-action-card"
elevation="1"
>
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Diensten claimen
</div>
<div class="text-body-2">
Schrijf je in voor diensten
</div>
<VCardText class="d-flex flex-column align-center text-center pa-4">
<VIcon
icon="tabler-calendar-plus"
size="28"
color="primary"
class="mb-2"
/>
<div class="text-subtitle-2 font-weight-bold mb-1">
Diensten Claimen
</div>
<div class="text-caption text-medium-emphasis">
Schrijf je in
</div>
</VCardText>
</VCard>
</VCol>
<VCol
@@ -129,21 +148,30 @@ const registeredLabel = computed(() => {
>
<VCard
:to="{ name: 'portal-profile' }"
variant="outlined"
class="pa-4 h-100 text-decoration-none"
class="h-100 text-decoration-none portal-action-card"
elevation="1"
>
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Profiel
</div>
<div class="text-body-2">
Gegevens bekijken
</div>
<VCardText class="d-flex flex-column align-center text-center pa-4">
<VIcon
icon="tabler-user"
size="28"
color="primary"
class="mb-2"
/>
<div class="text-subtitle-2 font-weight-bold mb-1">
Mijn Profiel
</div>
<div class="text-caption text-medium-emphasis">
Gegevens bekijken
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Upcoming shift -->
<div class="text-subtitle-1 font-weight-bold mb-2">
Komende shift
Komende dienst
</div>
<p
v-if="nextShiftSummary"
@@ -155,8 +183,57 @@ const registeredLabel = computed(() => {
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.
<RouterLink
:to="{ name: 'portal-claim-shifts' }"
class="text-primary font-weight-medium"
>
Diensten claimen
</RouterLink>
</p>
<!-- Quick stats -->
<div
v-if="upcomingCount !== undefined || availableCount !== null"
class="d-flex flex-wrap gap-4 mt-4 pt-4"
style="border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));"
>
<div
v-if="upcomingCount !== undefined"
class="text-body-2"
>
<VIcon
icon="tabler-calendar-check"
size="16"
class="me-1"
/>
Diensten ingepland: <strong>{{ upcomingCount }}</strong>
</div>
<RouterLink
v-if="availableCount !== null && availableCount !== undefined"
:to="{ name: 'portal-claim-shifts' }"
class="text-body-2 text-primary text-decoration-none"
>
<VIcon
icon="tabler-calendar-plus"
size="16"
class="me-1"
/>
Beschikbare diensten bekijken
</RouterLink>
</div>
</template>
</VCard>
</template>
<style scoped>
.portal-action-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
}
.portal-action-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
</style>

View File

@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/vue-query'
import { apiClient } from '@/lib/axios'
interface ApiResponse<T> {
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<ApiResponse<{ message: string }>>(
'/portal/profile',
payload,
)
return data.data
},
})
}
export function useUpdatePassword() {
return useMutation({
mutationFn: async (payload: PasswordUpdatePayload) => {
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
'/portal/password',
payload,
)
return data.data
},
})
}

View File

@@ -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
</script>
<template>
@@ -142,17 +138,28 @@ onMounted(async () => {
:key="day.date"
class="mb-6"
>
<h5 class="text-h5 mb-3">
{{ day.date_label }}
</h5>
<div class="d-flex align-center gap-2 mb-3">
<VIcon
icon="tabler-calendar"
size="20"
color="primary"
/>
<h5 class="text-h5 mb-0">
{{ day.date_label }}
</h5>
</div>
<div
v-for="slot in day.time_slots"
:key="slot.time_slot_id"
class="mb-4"
>
<div class="text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
{{ slot.name }} ({{ slot.start_time }} - {{ slot.end_time }})
<div class="d-flex align-center gap-2 text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
<VIcon
icon="tabler-clock"
size="16"
/>
{{ slot.name }} &middot; {{ slot.start_time }} - {{ slot.end_time }}
</div>
<VRow>
@@ -165,7 +172,8 @@ onMounted(async () => {
>
<VCard
variant="outlined"
class="h-100"
class="h-100 claim-card"
:class="{ 'claim-card--conflict': shift.has_conflict }"
>
<VCardItem>
<template #prepend>
@@ -173,49 +181,57 @@ onMounted(async () => {
v-if="shift.section_icon"
:icon="shift.section_icon"
size="24"
color="primary"
:color="shift.has_conflict ? 'disabled' : 'primary'"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ shift.title }}
</VCardTitle>
<VCardSubtitle>{{ shift.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="availabilityColor(shift.slots_available)"
size="small"
variant="tonal"
>
{{ shift.slots_available }}/{{ shift.slots_open_for_claiming }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div
v-if="shift.location_name"
class="text-body-2 mb-1"
>
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ shift.location_name }}
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2 mb-2">
<span v-if="shift.location_name">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ shift.location_name }}
</span>
<span v-if="shift.report_time">
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
Aanwezig: {{ shift.report_time }}
</span>
</div>
<div
v-if="shift.report_time"
class="text-body-2 mb-1"
>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
<!-- Availability progress -->
<div class="mb-2">
<div class="d-flex justify-space-between text-caption text-medium-emphasis mb-1">
<span>{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar</span>
</div>
<VProgressLinear
:model-value="((shift.slots_open_for_claiming - shift.slots_available) / shift.slots_open_for_claiming) * 100"
:color="availabilityColor(shift.slots_available)"
height="6"
rounded
/>
Aanwezig: {{ shift.report_time }}
</div>
<VChip
:color="availabilityColor(shift.slots_available)"
size="small"
variant="tonal"
class="mt-2 mb-2"
>
{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar
</VChip>
<div
v-if="shift.description"
class="mt-2"
@@ -252,15 +268,21 @@ onMounted(async () => {
density="compact"
class="mt-3"
>
<template #prepend>
<VIcon
icon="tabler-alert-triangle"
size="18"
/>
</template>
{{ shift.conflict_reason }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
variant="elevated"
block
:variant="shift.has_conflict ? 'tonal' : 'elevated'"
:disabled="shift.has_conflict || claimMutation.isPending.value"
:loading="claimMutation.isPending.value && selectedShift?.id === shift.id"
@click="openClaimDialog(shift, day.date_label, slot.start_time, slot.end_time)"
@@ -328,3 +350,9 @@ onMounted(async () => {
</VCol>
</VRow>
</template>
<style scoped>
.claim-card--conflict {
opacity: 0.6;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import StatusCard from '@/components/portal/StatusCard.vue'
import { usePortalStore } from '@/stores/usePortalStore'
import { useMyShifts } from '@/composables/api/usePortalShifts'
import type { PortalPersonPayload } from '@/types/portal'
definePage({
@@ -12,6 +13,10 @@ definePage({
})
const portal = usePortalStore()
const eventId = computed(() => portal.activeEventId)
// Fetch my shifts to show upcoming count
const { data: shifts } = useMyShifts(eventId)
const effectiveStatus = computed(() => {
const fromPerson = portal.currentPerson?.status
@@ -32,6 +37,8 @@ const eventTitle = computed(() => portal.activeEvent?.event_name ?? 'dit eveneme
const registeredAt = computed(() => portal.currentPerson?.created_at ?? null)
const upcomingCount = computed(() => shifts.value?.upcoming.length ?? 0)
function formatNextShift(person: PortalPersonPayload | null): string | null {
const list = person?.shift_assignments
if (!list?.length) return null
@@ -64,14 +71,12 @@ function formatNextShift(person: PortalPersonPayload | null): string | null {
const place = section ? `${section}` : ''
return `📅 ${dateStr}${timePart ? `, ${timePart}` : ''}${place}`
return `${dateStr}${timePart ? `, ${timePart}` : ''}${place}`
}
const nextShiftSummary = computed(() => formatNextShift(portal.currentPerson))
onMounted(async () => {
await portal.hydrateAfterAuth()
})
// Portal hydration now happens automatically in the router guard
</script>
<template>
@@ -126,6 +131,7 @@ onMounted(async () => {
:event-name="eventTitle"
:registered-at="registeredAt"
:next-shift-summary="nextShiftSummary"
:upcoming-count="upcomingCount"
/>
</template>
</VCol>

View File

@@ -58,11 +58,7 @@ async function confirmCancel() {
}
}
onMounted(async () => {
if (!portal.activeEventId) {
await portal.hydrateAfterAuth()
}
})
// Portal hydration now happens automatically in the router guard
</script>
<template>
@@ -130,7 +126,8 @@ onMounted(async () => {
v-for="assignment in shifts.upcoming"
:key="assignment.assignment_id"
variant="outlined"
class="mb-3"
class="mb-3 shift-card"
:class="`shift-card--${assignment.status}`"
>
<VCardItem>
<template #prepend>
@@ -419,3 +416,29 @@ onMounted(async () => {
</VCol>
</VRow>
</template>
<style scoped>
.shift-card {
border-inline-start: 3px solid transparent;
}
.shift-card--approved {
border-inline-start-color: rgb(var(--v-theme-success));
}
.shift-card--pending_approval {
border-inline-start-color: rgb(var(--v-theme-warning));
}
.shift-card--rejected {
border-inline-start-color: rgb(var(--v-theme-error));
}
.shift-card--cancelled {
border-inline-start-color: rgb(var(--v-theme-secondary));
}
.shift-card--completed {
border-inline-start-color: rgb(var(--v-theme-info));
}
</style>

View File

@@ -1,11 +1,4 @@
<script setup lang="ts">
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import authV2LoginIllustrationLight from '@images/pages/auth-v2-login-illustration-light.png'
import authV2LoginIllustrationDark from '@images/pages/auth-v2-login-illustration-dark.png'
import authV2LoginIllustrationBorderedLight from '@images/pages/auth-v2-login-illustration-bordered-light.png'
import authV2LoginIllustrationBorderedDark from '@images/pages/auth-v2-login-illustration-bordered-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
@@ -33,16 +26,6 @@ const isSubmitting = ref(false)
const passwordResetDone = computed(() => route.query.reset === '1')
const authThemeImg = useGenerateImageVariant(
authV2LoginIllustrationLight,
authV2LoginIllustrationDark,
authV2LoginIllustrationBorderedLight,
authV2LoginIllustrationBorderedDark,
true,
)
const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
function mapLoginErrorMessage(message: string | undefined): string {
if (!message) return 'Inloggen mislukt. Controleer je gegevens.'
if (message === 'Invalid credentials' || message.toLowerCase().includes('invalid credentials'))
@@ -84,161 +67,120 @@ async function onSubmit(): Promise<void> {
</script>
<template>
<!-- Logo -->
<RouterLink to="/">
<div class="auth-logo d-flex align-center gap-x-3">
<VIcon
icon="tabler-users-group"
size="28"
color="primary"
/>
<h1 class="auth-title">
Crewli
</h1>
</div>
</RouterLink>
<VRow
no-gutters
class="auth-wrapper bg-surface"
>
<!-- Left: Illustration -->
<VCol
md="8"
class="d-none d-md-flex"
<div class="login-page d-flex align-center justify-center" style="min-height: 100vh; background: #f5f5f5;">
<VCard
:max-width="440"
class="pa-6 pa-sm-8"
style="width: 100%;"
>
<div class="position-relative bg-background w-100 me-0">
<div
class="d-flex align-center justify-center w-100 h-100"
style="padding-inline: 6.25rem;"
<!-- Crewli branding -->
<div class="text-center mb-6">
<RouterLink
to="/"
class="d-inline-flex align-center gap-x-2 text-decoration-none"
>
<VImg
max-width="613"
:src="authThemeImg"
class="auth-illustration mt-16 mb-2"
<VIcon
icon="tabler-users-group"
size="32"
color="primary"
/>
</div>
<img
class="auth-footer-mask flip-in-rtl"
:src="authThemeMask"
alt="auth-footer-mask"
height="280"
width="100"
>
<span class="text-h4 font-weight-bold text-high-emphasis">
Crewli
</span>
</RouterLink>
</div>
</VCol>
<!-- Right: Login form -->
<VCol
cols="12"
md="4"
class="auth-card-v2 d-flex align-center justify-center"
>
<VCard
flat
:max-width="500"
class="mt-12 mt-sm-0 pa-6"
>
<VCardText>
<h4 class="text-h4 mb-1">
Welkom terug!
</h4>
<p class="mb-0">
Log in om je rooster en shifts te bekijken
</p>
</VCardText>
<VCardText class="pa-0">
<h4 class="text-h5 mb-1">
Welkom terug!
</h4>
<p class="text-body-2 text-medium-emphasis mb-6">
Log in om je rooster en diensten te bekijken
</p>
<VCardText>
<VAlert
v-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<VAlert
v-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<VTextField
v-model="form.email"
autofocus
label="E-mailadres"
type="email"
placeholder="je@email.nl"
autocomplete="email"
variant="outlined"
density="comfortable"
hide-details="auto"
required
/>
</VCol>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<VTextField
v-model="form.email"
autofocus
label="E-mailadres"
type="email"
placeholder="je@email.nl"
autocomplete="email"
variant="outlined"
density="comfortable"
hide-details="auto"
required
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="form.password"
label="Wachtwoord"
placeholder="Je wachtwoord"
autocomplete="current-password"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
required
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<VCol cols="12">
<VTextField
v-model="form.password"
label="Wachtwoord"
placeholder="Je wachtwoord"
autocomplete="current-password"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
required
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center flex-wrap justify-end my-4">
<RouterLink
to="/wachtwoord-vergeten"
class="text-primary text-body-2"
>
Wachtwoord vergeten?
</RouterLink>
</div>
<VBtn
block
type="submit"
color="primary"
:loading="isSubmitting"
<div class="d-flex align-center flex-wrap justify-end my-4">
<RouterLink
to="/wachtwoord-vergeten"
class="text-primary text-body-2"
>
Inloggen
</VBtn>
</VCol>
</VRow>
</VForm>
Wachtwoord vergeten?
</RouterLink>
</div>
<p class="text-body-2 text-center text-medium-emphasis mt-6 mb-0">
Nog geen account?
<RouterLink
to="/registreren"
class="text-primary font-weight-medium"
>
Meld je aan als vrijwilliger
</RouterLink>
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<VBtn
block
type="submit"
color="primary"
:loading="isSubmitting"
>
Inloggen
</VBtn>
</VCol>
</VRow>
</VForm>
<p class="text-body-2 text-center text-medium-emphasis mt-6 mb-0">
Nog geen account?
<RouterLink
to="/registreren"
class="text-primary font-weight-medium"
>
Meld je aan als vrijwilliger
</RouterLink>
</p>
</VCardText>
</VCard>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
definePage({
name: 'portal-profile',
meta: {
@@ -6,6 +10,130 @@ definePage({
requiresAuth: true,
},
})
const authStore = useAuthStore()
const portal = usePortalStore()
const updateProfileMutation = useUpdateProfile()
const updatePasswordMutation = useUpdatePassword()
const snackbar = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref('success')
// Profile form
const profileForm = ref({
first_name: '',
last_name: '',
phone: '',
date_of_birth: '',
remarks: '',
})
const profileError = ref<string | null>(null)
// Password form
const passwordForm = ref({
current_password: '',
password: '',
password_confirmation: '',
})
const passwordError = ref<string | null>(null)
const passwordFieldErrors = ref<Record<string, string>>({})
const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
// Populate profile form from current person data
watch(() => portal.currentPerson, (person) => {
if (person) {
profileForm.value = {
first_name: person.first_name ?? authStore.user?.first_name ?? '',
last_name: person.last_name ?? authStore.user?.last_name ?? '',
phone: person.phone ?? '',
date_of_birth: person.date_of_birth ?? '',
remarks: person.remarks ?? '',
}
}
}, { immediate: true })
const statusConfig: Record<string, { label: string; color: string }> = {
pending: { label: 'In afwachting', color: 'warning' },
approved: { label: 'Goedgekeurd', color: 'success' },
rejected: { label: 'Afgewezen', color: 'error' },
}
const effectiveStatus = computed(() => {
const s = portal.currentPerson?.status ?? portal.activeEvent?.person_status ?? 'pending'
return statusConfig[s] ?? statusConfig.pending
})
async function saveProfile() {
profileError.value = null
if (!portal.activeEventId) return
try {
const result = await updateProfileMutation.mutateAsync({
event_id: portal.activeEventId,
...profileForm.value,
phone: profileForm.value.phone || null,
date_of_birth: profileForm.value.date_of_birth || null,
remarks: profileForm.value.remarks || null,
})
// Refresh person data and auth user
await Promise.all([
portal.fetchCurrentPerson(),
authStore.fetchUser(),
])
snackbarMessage.value = result.message
snackbarColor.value = 'success'
snackbar.value = true
}
catch (err: any) {
const data = err?.response?.data
if (data?.errors) {
const firstError = Object.values(data.errors).flat()[0] as string
profileError.value = firstError
}
else {
profileError.value = data?.message ?? 'Er is een fout opgetreden.'
}
}
}
async function savePassword() {
passwordError.value = null
passwordFieldErrors.value = {}
try {
const result = await updatePasswordMutation.mutateAsync(passwordForm.value)
passwordForm.value = {
current_password: '',
password: '',
password_confirmation: '',
}
snackbarMessage.value = result.message
snackbarColor.value = 'success'
snackbar.value = true
}
catch (err: any) {
const data = err?.response?.data
if (data?.errors) {
passwordFieldErrors.value = {}
for (const [key, messages] of Object.entries(data.errors)) {
passwordFieldErrors.value[key] = (messages as string[])[0]
}
}
else {
passwordError.value = data?.message ?? 'Er is een fout opgetreden.'
}
}
}
</script>
<template>
@@ -15,14 +143,247 @@ definePage({
md="8"
lg="6"
>
<VCard class="text-center pa-6">
<VCardTitle class="text-h5">
Mijn Profiel
</VCardTitle>
<VCardSubtitle>
Beheer je persoonlijke gegevens
</VCardSubtitle>
</VCard>
<h4 class="text-h4 mb-4">
Mijn Profiel
</h4>
<!-- Loading state -->
<VSkeletonLoader
v-if="portal.isLoadingPerson && !portal.currentPerson"
type="card"
class="mb-4"
/>
<template v-else-if="portal.currentPerson">
<!-- Status & Event info -->
<VCard class="mb-4">
<VCardText>
<div class="d-flex align-center justify-space-between flex-wrap gap-2">
<div>
<div class="text-body-2 text-medium-emphasis">
Evenement
</div>
<div class="text-body-1 font-weight-medium">
{{ portal.activeEvent?.event_name }}
</div>
</div>
<VChip
:color="effectiveStatus.color"
variant="tonal"
size="small"
>
{{ effectiveStatus.label }}
</VChip>
</div>
</VCardText>
</VCard>
<!-- Profile form -->
<VCard class="mb-4">
<VCardTitle>Persoonlijke gegevens</VCardTitle>
<VCardText>
<VAlert
v-if="profileError"
type="error"
variant="tonal"
density="compact"
class="mb-4"
>
{{ profileError }}
</VAlert>
<VForm @submit.prevent="saveProfile">
<VRow>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.first_name"
label="Voornaam"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.last_name"
label="Achternaam"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol cols="12">
<VTextField
:model-value="portal.currentPerson.email || authStore.user?.email"
label="E-mailadres"
variant="outlined"
density="comfortable"
hide-details="auto"
readonly
prepend-inner-icon="tabler-lock"
/>
<p class="text-caption text-medium-emphasis mt-1 mb-0">
Je e-mailadres kan niet worden gewijzigd.
</p>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.phone"
label="Telefoonnummer"
variant="outlined"
density="comfortable"
hide-details="auto"
prepend-inner-icon="tabler-phone"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.date_of_birth"
label="Geboortedatum"
variant="outlined"
density="comfortable"
hide-details="auto"
type="date"
>
<template #prepend-inner>
<VIcon
icon="tabler-calendar"
size="20"
/>
</template>
</VTextField>
</VCol>
<VCol cols="12">
<VTextarea
v-model="profileForm.remarks"
label="Opmerkingen"
variant="outlined"
density="comfortable"
hide-details="auto"
rows="3"
placeholder="Allergieën, dieetwensen, overige opmerkingen..."
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="updateProfileMutation.isPending.value"
>
Opslaan
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<!-- Password change -->
<VCard>
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
<VCardText>
<VAlert
v-if="passwordError"
type="error"
variant="tonal"
density="compact"
class="mb-4"
>
{{ passwordError }}
</VAlert>
<VForm @submit.prevent="savePassword">
<VRow>
<VCol cols="12">
<VTextField
v-model="passwordForm.current_password"
label="Huidig wachtwoord"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="showCurrentPassword ? 'text' : 'password'"
:append-inner-icon="showCurrentPassword ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.current_password"
@click:append-inner="showCurrentPassword = !showCurrentPassword"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="passwordForm.password"
label="Nieuw wachtwoord"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="showNewPassword ? 'text' : 'password'"
:append-inner-icon="showNewPassword ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password"
@click:append-inner="showNewPassword = !showNewPassword"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="passwordForm.password_confirmation"
label="Bevestig nieuw wachtwoord"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="showConfirmPassword ? 'text' : 'password'"
:append-inner-icon="showConfirmPassword ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password_confirmation"
@click:append-inner="showConfirmPassword = !showConfirmPassword"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="updatePasswordMutation.isPending.value"
>
Wachtwoord wijzigen
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</template>
<VAlert
v-else
type="warning"
variant="tonal"
>
We konden je profiel niet laden. Probeer het later opnieuw.
</VAlert>
<!-- Snackbar -->
<VSnackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="4000"
>
{{ snackbarMessage }}
</VSnackbar>
</VCol>
</VRow>
</template>

View File

@@ -998,7 +998,6 @@ async function onSubmit() {
variant="outlined"
placeholder="Je voornaam"
:error-messages="errors.first_name"
:disabled="authStore.isAuthenticated"
density="comfortable"
hide-details="auto"
/>
@@ -1020,7 +1019,6 @@ async function onSubmit() {
variant="outlined"
placeholder="Je achternaam"
:error-messages="errors.last_name"
:disabled="authStore.isAuthenticated"
density="comfortable"
hide-details="auto"
/>

View File

@@ -1,5 +1,6 @@
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
@@ -11,6 +12,12 @@ export function setupGuards(router: Router) {
await authStore.initialize()
}
// Hydrate portal data once after auth is confirmed
if (authStore.isAuthenticated) {
const portalStore = usePortalStore()
await portalStore.hydrateIfNeeded()
}
const requiresAuth = to.meta.requiresAuth === true
// Public routes — no auth check needed

View File

@@ -162,10 +162,26 @@ export const usePortalStore = defineStore('portal', () => {
}
}
const isHydrated = ref(false)
let hydratePromise: Promise<void> | null = null
async function hydrateAfterAuth(): Promise<void> {
await loadUserEventsFromApiAndStorage()
resolveActiveEventId()
await fetchCurrentPerson()
isHydrated.value = true
}
/**
* Hydrate portal data if not already done. Safe to call multiple times —
* only the first call triggers the actual hydration.
*/
function hydrateIfNeeded(): Promise<void> {
if (isHydrated.value) return Promise.resolve()
if (!hydratePromise)
hydratePromise = hydrateAfterAuth().finally(() => { hydratePromise = null })
return hydratePromise
}
function setActiveEvent(eventId: string): void {
@@ -180,6 +196,8 @@ export const usePortalStore = defineStore('portal', () => {
userEvents.value = []
currentPerson.value = null
loadError.value = null
isHydrated.value = false
hydratePromise = null
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_EVENTS)
localStorage.removeItem(STORAGE_ACTIVE_EVENT)
@@ -193,9 +211,11 @@ export const usePortalStore = defineStore('portal', () => {
activeEvent,
isLoadingEvents,
isLoadingPerson,
isHydrated,
loadError,
savePendingEventFromRegistration,
hydrateAfterAuth,
hydrateIfNeeded,
setActiveEvent,
fetchCurrentPerson,
reset,

View File

@@ -44,7 +44,13 @@ export interface PortalPersonPayload {
id: string
event_id: string
status: string
first_name: string
last_name: string
full_name: string
email: string
phone: string | null
date_of_birth: string | null
remarks: string | null
created_at: string
shift_assignments?: Array<{
id: string