- Add throttle middleware to login (5/min), portal/token-auth (10/min), volunteer-register (5/min), and invitation routes (10/min) - Set Sanctum token expiration to 7 days - Remove billing_status from UpdateOrganisationRequest (super_admin only) - Revoke all Sanctum tokens on password reset - Strengthen password rules: min 8 chars, mixed case, numbers - Create SecurityHeaders middleware (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy) - Fix open redirect on all 3 login pages (validate ?to= starts with /) - Set APP_DEBUG=false in .env.example - Log failed login attempts with email, IP, user-agent - Log authorization failures (403) with user, IP, path, method - Harden mass assignment: remove user_id from Person, audit fields from ShiftAssignment, system fields from UserInvitation $fillable - Replace real DB records with factory make() in mail preview routes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
200 lines
5.5 KiB
Vue
200 lines
5.5 KiB
Vue
<script setup lang="ts">
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import { usePortalStore } from '@/stores/usePortalStore'
|
|
|
|
definePage({
|
|
name: 'login',
|
|
meta: {
|
|
layout: 'blank',
|
|
requiresAuth: false,
|
|
},
|
|
})
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const authStore = useAuthStore()
|
|
const portalStore = usePortalStore()
|
|
|
|
const form = ref({
|
|
email: '',
|
|
password: '',
|
|
})
|
|
|
|
const isPasswordVisible = ref(false)
|
|
const errorMessage = ref('')
|
|
const isSubmitting = ref(false)
|
|
|
|
const passwordResetDone = computed(() => route.query.reset === '1')
|
|
|
|
function mapLoginErrorMessage(message: string | undefined): string {
|
|
if (!message) return 'Inloggen mislukt. Controleer je gegevens.'
|
|
if (message === 'Invalid credentials' || message.toLowerCase().includes('invalid credentials'))
|
|
return 'Ongeldig e-mailadres of wachtwoord.'
|
|
|
|
return message
|
|
}
|
|
|
|
async function onSubmit(): Promise<void> {
|
|
errorMessage.value = ''
|
|
isSubmitting.value = true
|
|
try {
|
|
await authStore.login(form.value.email.trim(), form.value.password)
|
|
await portalStore.hydrateAfterAuth()
|
|
}
|
|
catch (error: unknown) {
|
|
if (error instanceof Error && error.message === 'Sessie kon niet worden gestart.') {
|
|
errorMessage.value = 'Je sessie kon niet worden geladen. Probeer het opnieuw.'
|
|
|
|
return
|
|
}
|
|
const ax = error as { response?: { data?: { message?: string } } }
|
|
errorMessage.value = mapLoginErrorMessage(ax.response?.data?.message)
|
|
|
|
return
|
|
}
|
|
finally {
|
|
isSubmitting.value = false
|
|
}
|
|
|
|
// Navigate after login — outside try/catch so navigation errors
|
|
// (e.g. stale dynamic imports) don't mask a successful login.
|
|
const rawRedirect = typeof route.query.to === 'string' ? route.query.to : ''
|
|
let redirect = rawRedirect.startsWith('/') ? rawRedirect : ''
|
|
|
|
// Smart redirect based on number of events
|
|
if (!redirect) {
|
|
const events = portalStore.userEvents
|
|
if (events.length === 1) {
|
|
redirect = `/evenementen/${events[0]!.event_id}`
|
|
}
|
|
else {
|
|
redirect = '/evenementen'
|
|
}
|
|
}
|
|
|
|
router.replace(redirect).catch(() => {
|
|
// Dynamic import can fail after Vite HMR; a full reload recovers.
|
|
window.location.href = redirect
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<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%;"
|
|
>
|
|
<!-- Crewli branding -->
|
|
<div class="text-center mb-6">
|
|
<RouterLink
|
|
to="/"
|
|
class="d-inline-flex align-center gap-x-2 text-decoration-none"
|
|
>
|
|
<VIcon
|
|
icon="tabler-users-group"
|
|
size="32"
|
|
color="primary"
|
|
/>
|
|
<span class="text-h4 font-weight-bold text-high-emphasis">
|
|
Crewli
|
|
</span>
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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"
|
|
/>
|
|
|
|
<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"
|
|
>
|
|
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>
|