Files
crewli/apps/portal/src/pages/login.vue
bert.hausmans 1028498705 security: round 1 — quick wins (rate limiting, headers, mass assignment, logging)
- 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>
2026-04-14 01:34:51 +02:00

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>