feat: account settings with Vuexy tab pattern and MFA banner fix

Restructures account/profile pages to match Vuexy's account-settings
tab pattern (Account, Security, Notifications) and fixes the MFA
enforcement banner that stayed visible after successful setup.

Backend:
- Add phone column to users table with migration
- Add PUT /me/profile endpoint for profile updates
- Create UpdateProfileRequest form request
- Update MeResource to include phone field

Organizer app:
- Rewrite account-settings as tabbed page (VTabs pill style + VWindow)
- Create AccountTab: avatar, profile form, email change, danger zone
- Create SecurityTab: password change, MFA method cards, backup codes,
  trusted devices, disable MFA danger zone
- Create NotificationsTab: placeholder with disabled toggles
- Fix MFA banner: set authStore.mfaSetupRequired = false on setup complete
- Update router guard to redirect to ?tab=security for MFA enforcement
- Update UserProfile menu links to use tab query params

Portal:
- Restructure profiel.vue with VTabs (Mijn profiel + Beveiliging)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 22:18:16 +02:00
parent cd2c775692
commit 79b7fe0b42
22 changed files with 1860 additions and 1101 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\UpdateProfileRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -13,6 +14,25 @@ use Illuminate\Validation\ValidationException;
final class AccountController extends Controller
{
/**
* PUT /api/v1/me/profile
* Authenticated user updates their profile.
*/
public function updateProfile(UpdateProfileRequest $request): JsonResponse
{
$user = $request->user();
$user->update($request->validated());
activity()
->causedBy($user)
->performedOn($user)
->log('user.profile.updated');
return $this->success(message: 'Je profiel is bijgewerkt.');
}
/**
* POST /api/v1/me/change-password
* Authenticated user changes their own password.

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'phone' => ['nullable', 'string', 'max:20'],
'date_of_birth' => ['nullable', 'date', 'before:today'],
'timezone' => ['required', 'string', 'timezone:all'],
'locale' => ['required', 'string', 'in:nl,en'],
];
}
}

View File

@@ -20,6 +20,7 @@ final class MeResource extends JsonResource
'full_name' => $this->full_name,
'date_of_birth' => $this->date_of_birth?->toDateString(),
'email' => $this->email,
'phone' => $this->phone,
'timezone' => $this->timezone,
'locale' => $this->locale,
'avatar' => $this->avatar,

View File

@@ -28,6 +28,7 @@ final class User extends Authenticatable
'last_name',
'date_of_birth',
'email',
'phone',
'password',
'timezone',
'locale',

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 20)->nullable()->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('phone');
});
}
};

View File

@@ -144,6 +144,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::delete('auth/trusted-devices', [TrustedDeviceController::class, 'destroyAll']);
// Account management (self-service)
Route::put('me/profile', [AccountController::class, 'updateProfile']);
Route::post('me/change-password', [AccountController::class, 'changePassword']);
Route::post('me/change-email', [EmailChangeController::class, 'request']);

View File

@@ -7,6 +7,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AccountTab: typeof import('./src/components/account-settings/AccountTab.vue')['default']
AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default']
AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default']
AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default']
@@ -76,6 +77,7 @@ declare module 'vue' {
MfaTotpSetupDialog: typeof import('./src/components/settings/MfaTotpSetupDialog.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
NotificationsTab: typeof import('./src/components/account-settings/NotificationsTab.vue')['default']
OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default']
PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default']
PersonDetailPanel: typeof import('./src/components/persons/PersonDetailPanel.vue')['default']
@@ -88,6 +90,7 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default']
SectionsShiftsPanel: typeof import('./src/components/sections/SectionsShiftsPanel.vue')['default']
SecurityTab: typeof import('./src/components/account-settings/SecurityTab.vue')['default']
ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default']
ShiftDetailPanel: typeof import('./src/components/shifts/ShiftDetailPanel.vue')['default']
Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default']

View File

@@ -0,0 +1,442 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { useUpdateProfile, useChangeEmail } from '@/composables/api/useAccount'
import type { UpdateProfilePayload } from '@/composables/api/useAccount'
import { avatarText } from '@core/utils/formatters'
const authStore = useAuthStore()
// Profile form
const profileForm = ref<UpdateProfilePayload>({
first_name: '',
last_name: '',
phone: '',
date_of_birth: '',
timezone: 'Europe/Amsterdam',
locale: 'nl',
})
const profileFieldErrors = ref<Record<string, string>>({})
const profileSuccess = ref('')
const updateProfileMutation = useUpdateProfile()
// Populate form from auth store
watch(
() => authStore.user,
(user) => {
if (user) {
profileForm.value = {
first_name: user.first_name,
last_name: user.last_name,
phone: user.phone ?? '',
date_of_birth: user.date_of_birth ?? '',
timezone: user.timezone,
locale: user.locale,
}
}
},
{ immediate: true },
)
async function handleProfileSave() {
profileFieldErrors.value = {}
profileSuccess.value = ''
updateProfileMutation.mutate(
{
...profileForm.value,
phone: profileForm.value.phone || null,
date_of_birth: profileForm.value.date_of_birth || null,
},
{
onSuccess: async (data) => {
profileSuccess.value = data.message
// Re-fetch user data to update the store
const { apiClient } = await import('@/lib/axios')
const { data: meData } = await apiClient.get<{ success: boolean; data: import('@/types/auth').MeResponse }>('/auth/me')
authStore.setUser(meData.data)
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
profileFieldErrors.value[key] = messages[0]
}
}
},
},
)
}
// Email change
const emailForm = ref({
new_email: '',
password: '',
})
const emailFieldErrors = ref<Record<string, string>>({})
const emailSuccess = ref('')
const showEmailPw = ref(false)
const changeEmailMutation = useChangeEmail()
async function handleEmailChange() {
emailFieldErrors.value = {}
emailSuccess.value = ''
changeEmailMutation.mutate(
{ ...emailForm.value, app: 'app' },
{
onSuccess: (data) => {
emailSuccess.value = data.message
emailForm.value = { new_email: '', password: '' }
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
emailFieldErrors.value[key] = messages[0]
}
}
},
},
)
}
// Avatar
const avatarSrc = computed((): string | null => {
const raw = authStore.user?.avatar
if (!raw)
return null
if (raw.startsWith('http://') || raw.startsWith('https://'))
return raw
const base = (import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/api\/v1\/?$/, '') ?? ''
return raw.startsWith('/') ? `${base}${raw}` : `${base}/${raw}`
})
// Delete account dialog
const showDeleteDialog = ref(false)
const deleteConfirmText = ref('')
const showDeleteSnackbar = ref(false)
function handleDeleteConfirm() {
showDeleteDialog.value = false
deleteConfirmText.value = ''
showDeleteSnackbar.value = true
}
// Timezone list
const timezones = [
'Europe/Amsterdam',
'Europe/Brussels',
'Europe/Berlin',
'Europe/London',
'Europe/Paris',
'Europe/Madrid',
'Europe/Rome',
'Europe/Zurich',
'Europe/Vienna',
'Europe/Stockholm',
'Europe/Oslo',
'Europe/Copenhagen',
'Europe/Helsinki',
'Europe/Warsaw',
'Europe/Prague',
'Europe/Lisbon',
'Europe/Dublin',
'Europe/Athens',
'Europe/Bucharest',
'Europe/Istanbul',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Sao_Paulo',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Singapore',
'Australia/Sydney',
]
const localeOptions = [
{ title: 'Nederlands', value: 'nl' },
{ title: 'English', value: 'en' },
]
</script>
<template>
<!-- Profile photo + personal details -->
<VCard class="mb-6">
<VCardText class="d-flex align-center gap-6">
<!-- Avatar -->
<VAvatar
rounded
size="100"
color="primary"
variant="tonal"
>
<VImg
v-if="avatarSrc"
:src="avatarSrc"
:alt="authStore.user?.full_name ?? ''"
cover
/>
<span
v-else
class="text-h4 font-weight-medium"
>
{{ avatarText(authStore.user?.full_name ?? '') }}
</span>
</VAvatar>
<div>
<h5 class="text-h5 mb-1">
{{ authStore.user?.full_name }}
</h5>
<p class="text-body-2 text-medium-emphasis mb-2">
{{ authStore.user?.email }}
</p>
<p class="text-caption text-disabled mb-0">
PNG, JPG of SVG. Max 2MB. (binnenkort beschikbaar)
</p>
</div>
</VCardText>
<VDivider />
<!-- Personal details form -->
<VCardText class="mt-3">
<VAlert
v-if="profileSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-4"
>
{{ profileSuccess }}
</VAlert>
<VForm @submit.prevent="handleProfileSave">
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="profileForm.first_name"
label="Voornaam"
:error-messages="profileFieldErrors.first_name"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="profileForm.last_name"
label="Achternaam"
:error-messages="profileFieldErrors.last_name"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="profileForm.phone"
label="Telefoonnummer"
:error-messages="profileFieldErrors.phone"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="profileForm.date_of_birth"
label="Geboortedatum"
type="date"
:error-messages="profileFieldErrors.date_of_birth"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppAutocomplete
v-model="profileForm.timezone"
label="Tijdzone"
:items="timezones"
:error-messages="profileFieldErrors.timezone"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="profileForm.locale"
label="Taal"
:items="localeOptions"
:error-messages="profileFieldErrors.locale"
/>
</VCol>
</VRow>
<div class="d-flex flex-wrap gap-4 mt-6">
<VBtn
type="submit"
:loading="updateProfileMutation.isPending.value"
>
Wijzigingen opslaan
</VBtn>
<VBtn
color="secondary"
variant="tonal"
type="reset"
>
Annuleren
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<!-- Email change -->
<VCard class="mb-6">
<VCardItem>
<VCardTitle>E-mailadres wijzigen</VCardTitle>
</VCardItem>
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
Huidig e-mailadres: <strong>{{ authStore.user?.email }}</strong>
</p>
<VAlert
v-if="emailSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-4"
>
{{ emailSuccess }}
</VAlert>
<VForm @submit.prevent="handleEmailChange">
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="emailForm.new_email"
label="Nieuw e-mailadres"
type="email"
:error-messages="emailFieldErrors.new_email"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="emailForm.password"
label="Huidig wachtwoord"
:type="showEmailPw ? 'text' : 'password'"
:append-inner-icon="showEmailPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="emailFieldErrors.password"
hint="Ter bevestiging van je identiteit"
persistent-hint
@click:append-inner="showEmailPw = !showEmailPw"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
:loading="changeEmailMutation.isPending.value"
:disabled="!emailForm.new_email || !emailForm.password"
>
Verificatiemail versturen
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<!-- Danger zone: Account verwijderen -->
<VCard
class="border-error"
variant="outlined"
>
<VCardItem>
<VCardTitle class="text-error">
Account verwijderen
</VCardTitle>
</VCardItem>
<VCardText>
<p class="text-body-2 mb-4">
Als je je account verwijdert, worden al je gegevens permanent verwijderd.
Deze actie kan niet ongedaan worden gemaakt.
</p>
<VBtn
color="error"
variant="outlined"
@click="showDeleteDialog = true"
>
Account verwijderen
</VBtn>
</VCardText>
</VCard>
<!-- Delete confirmation dialog -->
<VDialog
v-model="showDeleteDialog"
max-width="440"
>
<VCard>
<VCardTitle class="text-h6">
Account verwijderen
</VCardTitle>
<VCardText>
<VAlert
type="error"
variant="tonal"
class="mb-4"
>
Dit kan niet ongedaan worden gemaakt. Al je gegevens worden permanent verwijderd.
</VAlert>
<AppTextField
v-model="deleteConfirmText"
label="Typ DELETE om te bevestigen"
placeholder="DELETE"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="showDeleteDialog = false; deleteConfirmText = ''"
>
Annuleren
</VBtn>
<VBtn
color="error"
:disabled="deleteConfirmText !== 'DELETE'"
@click="handleDeleteConfirm"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showDeleteSnackbar"
color="info"
:timeout="5000"
>
Neem contact op met de beheerder om je account te verwijderen.
</VSnackbar>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
const notifications = ref([
{ type: 'Shift toewijzingen', email: true },
{ type: 'Registratie updates', email: true },
{ type: 'Evenement updates', email: true },
{ type: 'Systeem meldingen', email: true },
])
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>E-mailmeldingen</VCardTitle>
<template #subtitle>
Dit wordt binnenkort beschikbaar
</template>
</VCardItem>
<VCardText class="px-0">
<VDivider />
<VTable class="text-no-wrap">
<thead>
<tr>
<th>Type</th>
<th class="text-center">
E-MAIL
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in notifications"
:key="item.type"
>
<td class="text-body-1 text-high-emphasis">
{{ item.type }}
</td>
<td class="text-center">
<VCheckbox
v-model="item.email"
disabled
class="d-inline-flex"
/>
</td>
</tr>
</tbody>
</VTable>
<VDivider />
</VCardText>
<VCardText>
<VAlert
type="info"
variant="tonal"
>
Meldingsvoorkeuren zijn binnenkort beschikbaar. Op dit moment ontvang je alle meldingen via e-mail.
</VAlert>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,639 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { useChangePassword } from '@/composables/api/useAccount'
import {
useMfaStatus,
useTrustedDevices,
useRevokeDevice,
useRevokeAllDevices,
useRegenerateBackupCodes,
} from '@/composables/api/useMfa'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
const authStore = useAuthStore()
// ─── Password Change ───
const passwordForm = ref({
current_password: '',
password: '',
password_confirmation: '',
})
const passwordFieldErrors = ref<Record<string, string>>({})
const passwordSuccess = ref('')
const showCurrentPw = ref(false)
const showNewPw = ref(false)
const showConfirmPw = ref(false)
const changePasswordMutation = useChangePassword()
async function handlePasswordChange() {
passwordFieldErrors.value = {}
passwordSuccess.value = ''
changePasswordMutation.mutate(passwordForm.value, {
onSuccess: (data) => {
passwordSuccess.value = data.message
passwordForm.value = {
current_password: '',
password: '',
password_confirmation: '',
}
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
passwordFieldErrors.value[key] = messages[0]
}
}
},
})
}
// ─── MFA ───
const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
const regenerateCodesMutation = useRegenerateBackupCodes()
const showTotpSetup = ref(false)
const showEmailSetup = ref(false)
const showDisableDialog = ref(false)
const showRegenerateDialog = ref(false)
const regenerateCode = ref('')
const regeneratedCodes = ref<string[]>([])
const regenerateError = ref('')
const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
const totpConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'totp')
const emailConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'email')
const backupCodesRemaining = computed(() => mfaStatus.value?.backup_codes_remaining ?? 0)
const backupCodesColor = computed(() => {
if (backupCodesRemaining.value > 5) return 'success'
if (backupCodesRemaining.value >= 3) return 'warning'
return 'error'
})
function onSetupCompleted() {
refetchMfaStatus()
// Immediately clear the MFA enforcement state so the banner disappears
// and the router guard no longer redirects
authStore.mfaSetupRequired = false
}
function onDisabled() {
refetchMfaStatus()
refetchDevices()
}
async function handleRevokeDevice(id: string) {
await revokeDeviceMutation.mutateAsync(id)
refetchDevices()
}
async function handleRevokeAllDevices() {
await revokeAllMutation.mutateAsync()
refetchDevices()
}
async function handleRegenerateBackupCodes() {
regenerateError.value = ''
try {
const data = await regenerateCodesMutation.mutateAsync({ code: regenerateCode.value })
regeneratedCodes.value = data.backup_codes
refetchMfaStatus()
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
regenerateError.value = ax.response?.data?.message ?? 'Kon codes niet genereren.'
regenerateCode.value = ''
}
}
function copyRegeneratedCodes() {
navigator.clipboard.writeText(regeneratedCodes.value.join('\n'))
}
</script>
<template>
<!-- Enforcement notice -->
<VAlert
v-if="mfaStatus?.is_required && !isEnabled"
type="warning"
variant="tonal"
class="mb-6"
>
Je organisatie vereist tweestapsverificatie. Stel het nu in om het platform te kunnen gebruiken.
</VAlert>
<!-- Card 1: Wachtwoord wijzigen -->
<VCard class="mb-6">
<VCardItem>
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
</VCardItem>
<VForm @submit.prevent="handlePasswordChange">
<VCardText class="pt-0">
<VAlert
v-if="passwordSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-4"
>
{{ passwordSuccess }}
</VAlert>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="passwordForm.current_password"
label="Huidig wachtwoord"
:type="showCurrentPw ? 'text' : 'password'"
:append-inner-icon="showCurrentPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.current_password"
placeholder="············"
@click:append-inner="showCurrentPw = !showCurrentPw"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="passwordForm.password"
label="Nieuw wachtwoord"
:type="showNewPw ? 'text' : 'password'"
:append-inner-icon="showNewPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password"
placeholder="············"
@click:append-inner="showNewPw = !showNewPw"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="passwordForm.password_confirmation"
label="Bevestig nieuw wachtwoord"
:type="showConfirmPw ? 'text' : 'password'"
:append-inner-icon="showConfirmPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password_confirmation"
placeholder="············"
@click:append-inner="showConfirmPw = !showConfirmPw"
/>
</VCol>
</VRow>
</VCardText>
<VCardText>
<h6 class="text-h6 text-medium-emphasis mb-4">
Wachtwoordvereisten:
</h6>
<VList class="card-list">
<VListItem>
<template #prepend>
<VIcon
size="10"
icon="tabler-circle-filled"
/>
</template>
<span class="text-medium-emphasis">Minimaal 8 tekens</span>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
size="10"
icon="tabler-circle-filled"
/>
</template>
<span class="text-medium-emphasis">Minimaal 1 hoofdletter en 1 kleine letter</span>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
size="10"
icon="tabler-circle-filled"
/>
</template>
<span class="text-medium-emphasis">Minimaal 1 cijfer</span>
</VListItem>
</VList>
</VCardText>
<VCardText class="d-flex flex-wrap gap-4">
<VBtn
type="submit"
:loading="changePasswordMutation.isPending.value"
>
Wachtwoord wijzigen
</VBtn>
<VBtn
color="secondary"
variant="tonal"
type="reset"
@click="passwordForm = { current_password: '', password: '', password_confirmation: '' }; passwordSuccess = ''"
>
Annuleren
</VBtn>
</VCardText>
</VForm>
</VCard>
<!-- Card 2: Tweestapsverificatie -->
<VCard class="mb-6">
<VCardItem>
<VCardTitle>Tweestapsverificatie</VCardTitle>
</VCardItem>
<VCardText>
<!-- MFA method cards -->
<VRow class="mb-4">
<!-- TOTP card -->
<VCol
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="pa-4 h-100"
>
<div class="d-flex align-center mb-3">
<VAvatar
color="primary"
variant="tonal"
size="40"
rounded
class="me-3"
>
<VIcon
icon="tabler-device-mobile"
size="24"
/>
</VAvatar>
<div>
<h6 class="text-h6">
Authenticator app
</h6>
<VChip
:color="totpConfigured ? 'success' : 'default'"
variant="tonal"
size="small"
class="mt-1"
>
{{ totpConfigured ? 'Geconfigureerd' : 'Niet ingesteld' }}
</VChip>
</div>
</div>
<p
v-if="totpConfigured && mfaStatus?.method === 'totp'"
class="text-body-2 text-medium-emphasis mb-3"
>
Primaire methode
</p>
<p
v-else-if="!totpConfigured"
class="text-body-2 text-medium-emphasis mb-3"
>
Gebruik Google Authenticator, Authy of een andere app.
</p>
<VBtn
v-if="totpConfigured"
variant="tonal"
size="small"
@click="showTotpSetup = true"
>
Opnieuw instellen
</VBtn>
<VBtn
v-else
variant="tonal"
size="small"
@click="showTotpSetup = true"
>
Instellen
</VBtn>
</VCard>
</VCol>
<!-- Email card -->
<VCol
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="pa-4 h-100"
>
<div class="d-flex align-center mb-3">
<VAvatar
color="primary"
variant="tonal"
size="40"
rounded
class="me-3"
>
<VIcon
icon="tabler-mail"
size="24"
/>
</VAvatar>
<div>
<h6 class="text-h6">
E-mailcode
</h6>
<VChip
:color="emailConfigured ? 'success' : 'default'"
variant="tonal"
size="small"
class="mt-1"
>
{{ emailConfigured ? 'Geconfigureerd' : 'Niet ingesteld' }}
</VChip>
</div>
</div>
<p
v-if="emailConfigured && mfaStatus?.method === 'email'"
class="text-body-2 text-medium-emphasis mb-3"
>
Primaire methode
</p>
<p
v-else-if="!emailConfigured"
class="text-body-2 text-medium-emphasis mb-3"
>
Ontvang een code per e-mail bij elke login.
</p>
<VBtn
v-if="emailConfigured"
variant="tonal"
size="small"
@click="showEmailSetup = true"
>
Opnieuw instellen
</VBtn>
<VBtn
v-else
variant="tonal"
size="small"
@click="showEmailSetup = true"
>
Instellen
</VBtn>
</VCard>
</VCol>
</VRow>
<!-- Backup codes status -->
<template v-if="isEnabled">
<VDivider class="mb-4" />
<div class="d-flex align-center justify-space-between mb-2">
<div>
<span class="text-body-1 font-weight-medium">Backup codes</span>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ backupCodesRemaining }} van 10 resterend
</p>
</div>
<VBtn
variant="tonal"
size="small"
@click="showRegenerateDialog = true; regeneratedCodes = []; regenerateCode = ''; regenerateError = ''"
>
Nieuwe codes genereren
</VBtn>
</div>
<VProgressLinear
:model-value="backupCodesRemaining * 10"
:color="backupCodesColor"
rounded
height="6"
class="mb-2"
/>
</template>
</VCardText>
</VCard>
<!-- Card 2b: MFA disable (danger zone) -->
<VCard
v-if="isEnabled"
class="mb-6 border-error"
variant="outlined"
>
<VCardText class="d-flex align-center justify-space-between">
<div>
<h6 class="text-h6 text-error mb-1">
Tweestapsverificatie uitschakelen
</h6>
<p class="text-body-2 text-medium-emphasis mb-0">
Dit vermindert de beveiliging van je account.
</p>
</div>
<VBtn
color="error"
variant="tonal"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
</VCardText>
</VCard>
<!-- Card 3: Vertrouwde apparaten -->
<VCard
v-if="isEnabled"
class="mb-6"
>
<VCardItem>
<template #default>
<VCardTitle>Vertrouwde apparaten</VCardTitle>
</template>
<template #append>
<VBtn
v-if="trustedDevices && trustedDevices.length > 1"
variant="tonal"
size="small"
color="error"
:loading="revokeAllMutation.isPending.value"
@click="handleRevokeAllDevices"
>
Alles intrekken
</VBtn>
</template>
</VCardItem>
<VCardText>
<template v-if="trustedDevices && trustedDevices.length > 0">
<VList>
<VListItem
v-for="device in trustedDevices"
:key="device.id"
class="px-0"
>
<template #prepend>
<VIcon
icon="tabler-device-desktop"
size="24"
class="me-3"
/>
</template>
<VListItemTitle>{{ device.device_name ?? 'Onbekend apparaat' }}</VListItemTitle>
<VListItemSubtitle>
IP: {{ device.ip_address }}
<span v-if="device.last_used_at">
&middot; Laatst gebruikt: {{ new Date(device.last_used_at).toLocaleDateString('nl-NL') }}
</span>
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
size="small"
color="error"
icon="tabler-trash"
:loading="revokeDeviceMutation.isPending.value"
@click="handleRevokeDevice(device.id)"
/>
</template>
</VListItem>
</VList>
</template>
<p
v-else
class="text-body-2 text-medium-emphasis"
>
Geen vertrouwde apparaten. Wanneer je inlogt met MFA kun je ervoor kiezen een apparaat te onthouden.
</p>
</VCardText>
</VCard>
<!-- Setup dialogs -->
<MfaTotpSetupDialog
v-model="showTotpSetup"
@completed="onSetupCompleted"
/>
<MfaEmailSetupDialog
v-model="showEmailSetup"
:user-email="authStore.user?.email ?? ''"
@completed="onSetupCompleted"
/>
<MfaDisableDialog
v-model="showDisableDialog"
:current-method="mfaStatus?.method ?? null"
@disabled="onDisabled"
/>
<!-- Regenerate backup codes dialog -->
<VDialog
v-model="showRegenerateDialog"
max-width="460"
>
<VCard>
<VCardTitle class="pt-4">
Nieuwe backup codes genereren
</VCardTitle>
<VCardText v-if="regeneratedCodes.length === 0">
<VAlert
type="info"
variant="tonal"
class="mb-4"
>
Dit vervangt al je huidige backup codes. Oude codes werken niet meer.
</VAlert>
<VAlert
v-if="regenerateError"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ regenerateError }}
</VAlert>
<p class="text-body-2 mb-2">
Voer je authenticator code in ter bevestiging
</p>
<AppTextField
v-model="regenerateCode"
placeholder="123456"
autofocus
/>
</VCardText>
<VCardText v-else>
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
Nieuwe backup codes gegenereerd. Bewaar ze veilig.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in regeneratedCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyRegeneratedCodes"
>
Kopieer
</VBtn>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="showRegenerateDialog = false"
>
{{ regeneratedCodes.length > 0 ? 'Sluiten' : 'Annuleren' }}
</VBtn>
<VBtn
v-if="regeneratedCodes.length === 0"
color="primary"
:loading="regenerateCodesMutation.isPending.value"
:disabled="!regenerateCode"
@click="handleRegenerateBackupCodes"
>
Genereren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style lang="scss">
.card-list {
--v-card-list-gap: 8px;
}
</style>

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/vue-query'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
@@ -8,6 +8,15 @@ interface ApiResponse<T> {
message: string
}
export interface UpdateProfilePayload {
first_name: string
last_name: string
phone?: string | null
date_of_birth?: string | null
timezone: string
locale: string
}
export interface ChangePasswordPayload {
current_password: string
password: string
@@ -24,6 +33,23 @@ export interface AdminChangeEmailPayload {
new_email: string
}
export function useUpdateProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: UpdateProfilePayload) => {
const { data } = await apiClient.put<ApiResponse<null>>(
'/me/profile',
payload,
)
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
export function useChangePassword() {
return useMutation({
mutationFn: async (payload: ChangePasswordPayload) => {

View File

@@ -76,18 +76,18 @@ function handleLogout() {
<VDivider class="my-2" />
<VListItem :to="{ name: 'account-settings' }">
<VListItem :to="{ name: 'account-settings', query: { tab: 'account' } }">
<template #prepend>
<VIcon
class="me-2"
icon="tabler-settings"
icon="tabler-user"
size="22"
/>
</template>
<VListItemTitle>Accountinstellingen</VListItemTitle>
<VListItemTitle>Mijn profiel</VListItemTitle>
</VListItem>
<VListItem :to="{ name: 'account-settings-security' }">
<VListItem :to="{ name: 'account-settings', query: { tab: 'security' } }">
<template #prepend>
<VIcon
class="me-2"

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { useChangePassword, useChangeEmail } from '@/composables/api/useAccount'
import AccountTab from '@/components/account-settings/AccountTab.vue'
import SecurityTab from '@/components/account-settings/SecurityTab.vue'
import NotificationsTab from '@/components/account-settings/NotificationsTab.vue'
definePage({
meta: {
@@ -8,214 +9,60 @@ definePage({
},
})
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
// Password change
const passwordForm = ref({
current_password: '',
password: '',
password_confirmation: '',
const tabs = [
{ title: 'Account', icon: 'tabler-user', value: 'account' },
{ title: 'Beveiliging', icon: 'tabler-shield-lock', value: 'security' },
{ title: 'Meldingen', icon: 'tabler-bell', value: 'notifications' },
]
const activeTab = computed({
get: () => {
const tab = route.query.tab as string | undefined
return tabs.some(t => t.value === tab) ? tab! : 'account'
},
set: (val: string) => {
router.replace({ query: { ...route.query, tab: val } })
},
})
const passwordFieldErrors = ref<Record<string, string>>({})
const passwordSuccess = ref('')
const showCurrentPw = ref(false)
const showNewPw = ref(false)
const showConfirmPw = ref(false)
const changePasswordMutation = useChangePassword()
async function handlePasswordChange() {
passwordFieldErrors.value = {}
passwordSuccess.value = ''
changePasswordMutation.mutate(passwordForm.value, {
onSuccess: (data) => {
passwordSuccess.value = data.message
passwordForm.value = {
current_password: '',
password: '',
password_confirmation: '',
}
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
passwordFieldErrors.value[key] = messages[0]
}
}
},
})
}
// Email change
const emailForm = ref({
new_email: '',
password: '',
})
const emailFieldErrors = ref<Record<string, string>>({})
const emailSuccess = ref('')
const showEmailPw = ref(false)
const changeEmailMutation = useChangeEmail()
async function handleEmailChange() {
emailFieldErrors.value = {}
emailSuccess.value = ''
changeEmailMutation.mutate(
{ ...emailForm.value, app: 'app' },
{
onSuccess: (data) => {
emailSuccess.value = data.message
emailForm.value = { new_email: '', password: '' }
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
emailFieldErrors.value[key] = messages[0]
}
}
},
},
)
}
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
<div>
<VTabs
v-model="activeTab"
class="v-tabs-pill mb-6"
>
<h4 class="text-h4 mb-6">
Accountinstellingen
</h4>
<VTab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
>
<VIcon
:icon="tab.icon"
size="20"
class="me-2"
/>
{{ tab.title }}
</VTab>
</VTabs>
<!-- Email change -->
<VCard class="mb-6">
<VCardTitle>E-mailadres wijzigen</VCardTitle>
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
Huidig e-mailadres: <strong>{{ authStore.user?.email }}</strong>
</p>
<VForm @submit.prevent="handleEmailChange">
<VRow>
<VCol cols="12">
<AppTextField
v-model="emailForm.new_email"
label="Nieuw e-mailadres"
type="email"
:error-messages="emailFieldErrors.new_email"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="emailForm.password"
label="Huidig wachtwoord"
:type="showEmailPw ? 'text' : 'password'"
:append-inner-icon="showEmailPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="emailFieldErrors.password"
hint="Ter bevestiging van je identiteit"
persistent-hint
@click:append-inner="showEmailPw = !showEmailPw"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="changeEmailMutation.isPending.value"
:disabled="!emailForm.new_email || !emailForm.password"
>
Verificatiemail versturen
</VBtn>
</div>
</VForm>
<VAlert
v-if="emailSuccess"
type="success"
variant="tonal"
density="compact"
class="mt-4"
>
{{ emailSuccess }}
</VAlert>
</VCardText>
</VCard>
<!-- Password change -->
<VCard>
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
<VCardText>
<VForm @submit.prevent="handlePasswordChange">
<VRow>
<VCol cols="12">
<AppTextField
v-model="passwordForm.current_password"
label="Huidig wachtwoord"
:type="showCurrentPw ? 'text' : 'password'"
:append-inner-icon="showCurrentPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.current_password"
@click:append-inner="showCurrentPw = !showCurrentPw"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<AppTextField
v-model="passwordForm.password"
label="Nieuw wachtwoord"
:type="showNewPw ? 'text' : 'password'"
:append-inner-icon="showNewPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password"
@click:append-inner="showNewPw = !showNewPw"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<AppTextField
v-model="passwordForm.password_confirmation"
label="Bevestig nieuw wachtwoord"
:type="showConfirmPw ? 'text' : 'password'"
:append-inner-icon="showConfirmPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password_confirmation"
@click:append-inner="showConfirmPw = !showConfirmPw"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="changePasswordMutation.isPending.value"
>
Wachtwoord wijzigen
</VBtn>
</div>
</VForm>
<VAlert
v-if="passwordSuccess"
type="success"
variant="tonal"
density="compact"
class="mt-4"
>
{{ passwordSuccess }}
</VAlert>
</VCardText>
</VCard>
</VCol>
</VRow>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
:touch="false"
>
<VWindowItem value="account">
<AccountTab />
</VWindowItem>
<VWindowItem value="security">
<SecurityTab />
</VWindowItem>
<VWindowItem value="notifications">
<NotificationsTab />
</VWindowItem>
</VWindow>
</div>
</template>

View File

@@ -1,398 +0,0 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import {
useMfaStatus,
useTrustedDevices,
useRevokeDevice,
useRevokeAllDevices,
useRegenerateBackupCodes,
} from '@/composables/api/useMfa'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
definePage({
meta: {
navActiveLink: 'account-settings',
},
})
const authStore = useAuthStore()
const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
const regenerateCodesMutation = useRegenerateBackupCodes()
const showTotpSetup = ref(false)
const showEmailSetup = ref(false)
const showDisableDialog = ref(false)
const showRegenerateDialog = ref(false)
const regenerateCode = ref('')
const regeneratedCodes = ref<string[]>([])
const regenerateError = ref('')
const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
const methodLabel = computed(() => {
if (mfaStatus.value?.method === 'totp') return 'Authenticator app'
if (mfaStatus.value?.method === 'email') return 'E-mailcode'
return null
})
function onSetupCompleted() {
refetchMfaStatus()
}
function onDisabled() {
refetchMfaStatus()
refetchDevices()
}
async function handleRevokeDevice(id: string) {
await revokeDeviceMutation.mutateAsync(id)
refetchDevices()
}
async function handleRevokeAllDevices() {
await revokeAllMutation.mutateAsync()
refetchDevices()
}
async function handleRegenerateBackupCodes() {
regenerateError.value = ''
try {
const data = await regenerateCodesMutation.mutateAsync({ code: regenerateCode.value })
regeneratedCodes.value = data.backup_codes
refetchMfaStatus()
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
regenerateError.value = ax.response?.data?.message ?? 'Kon codes niet genereren.'
regenerateCode.value = ''
}
}
function copyRegeneratedCodes() {
navigator.clipboard.writeText(regeneratedCodes.value.join('\n'))
}
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
>
<h4 class="text-h4 mb-6">
Beveiliging
</h4>
<!-- Enforcement notice -->
<VAlert
v-if="mfaStatus?.is_required && !isEnabled"
type="warning"
variant="tonal"
class="mb-6"
>
Je organisatie vereist tweestapsverificatie. Stel het nu in om het platform te kunnen gebruiken.
</VAlert>
<!-- Section 1: Tweestapsverificatie -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center">
<VIcon
icon="tabler-shield-lock"
class="me-2"
/>
Tweestapsverificatie
</VCardTitle>
<VCardText>
<!-- MFA NOT enabled -->
<template v-if="!isEnabled">
<p class="text-body-1 mb-4">
Bescherm je account met een extra beveiligingslaag. Kies een methode:
</p>
<VRow>
<VCol
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="cursor-pointer pa-4"
@click="showTotpSetup = true"
>
<div class="d-flex align-center mb-2">
<VIcon
icon="tabler-device-mobile"
size="28"
color="primary"
class="me-2"
/>
<span class="text-subtitle-1 font-weight-medium">Authenticator app</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Aanbevolen. Gebruik Google Authenticator, Authy of een andere app.
</p>
</VCard>
</VCol>
<VCol
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="cursor-pointer pa-4"
@click="showEmailSetup = true"
>
<div class="d-flex align-center mb-2">
<VIcon
icon="tabler-mail"
size="28"
color="primary"
class="me-2"
/>
<span class="text-subtitle-1 font-weight-medium">E-mailcode</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Ontvang een code per e-mail bij elke login.
</p>
</VCard>
</VCol>
</VRow>
</template>
<!-- MFA IS enabled -->
<template v-else>
<div class="d-flex align-center justify-space-between mb-4">
<div>
<VChip
color="success"
variant="tonal"
size="small"
class="me-2"
>
Ingeschakeld
</VChip>
<span class="text-body-1">via {{ methodLabel }}</span>
</div>
</div>
<!-- Backup codes -->
<div class="d-flex align-center justify-space-between mb-3 pa-3 rounded" style="background: rgb(var(--v-theme-surface-variant));">
<div>
<span class="text-body-1 font-weight-medium">Backup codes</span>
<br>
<span class="text-body-2 text-medium-emphasis">
{{ mfaStatus?.backup_codes_remaining ?? 0 }} van 10 resterend
</span>
</div>
<VBtn
variant="tonal"
size="small"
@click="showRegenerateDialog = true; regeneratedCodes = []; regenerateCode = ''; regenerateError = ''"
>
Nieuwe codes genereren
</VBtn>
</div>
<VBtn
color="error"
variant="tonal"
class="mt-2"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
</template>
</VCardText>
</VCard>
<!-- Section 2: Vertrouwde apparaten -->
<VCard
v-if="isEnabled"
class="mb-6"
>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<VIcon
icon="tabler-devices"
class="me-2"
/>
Vertrouwde apparaten
</div>
<VBtn
v-if="trustedDevices && trustedDevices.length > 1"
variant="tonal"
size="small"
color="error"
:loading="revokeAllMutation.isPending.value"
@click="handleRevokeAllDevices"
>
Alles intrekken
</VBtn>
</VCardTitle>
<VCardText>
<template v-if="trustedDevices && trustedDevices.length > 0">
<VList>
<VListItem
v-for="device in trustedDevices"
:key="device.id"
class="px-0"
>
<template #prepend>
<VIcon
icon="tabler-device-desktop"
size="24"
class="me-3"
/>
</template>
<VListItemTitle>{{ device.device_name ?? 'Onbekend apparaat' }}</VListItemTitle>
<VListItemSubtitle>
IP: {{ device.ip_address }}
<span v-if="device.last_used_at">
&middot; Laatst gebruikt: {{ new Date(device.last_used_at).toLocaleDateString('nl-NL') }}
</span>
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
size="small"
color="error"
icon="tabler-trash"
:loading="revokeDeviceMutation.isPending.value"
@click="handleRevokeDevice(device.id)"
/>
</template>
</VListItem>
</VList>
</template>
<p
v-else
class="text-body-2 text-medium-emphasis"
>
Geen vertrouwde apparaten. Wanneer je inlogt met MFA kun je ervoor kiezen een apparaat te onthouden.
</p>
</VCardText>
</VCard>
<!-- Setup dialogs -->
<MfaTotpSetupDialog
v-model="showTotpSetup"
@completed="onSetupCompleted"
/>
<MfaEmailSetupDialog
v-model="showEmailSetup"
:user-email="authStore.user?.email ?? ''"
@completed="onSetupCompleted"
/>
<MfaDisableDialog
v-model="showDisableDialog"
:current-method="mfaStatus?.method ?? null"
@disabled="onDisabled"
/>
<!-- Regenerate backup codes dialog -->
<VDialog
v-model="showRegenerateDialog"
max-width="460"
>
<VCard>
<VCardTitle class="pt-4">
Nieuwe backup codes genereren
</VCardTitle>
<VCardText v-if="regeneratedCodes.length === 0">
<VAlert
type="info"
variant="tonal"
class="mb-4"
>
Dit vervangt al je huidige backup codes. Oude codes werken niet meer.
</VAlert>
<VAlert
v-if="regenerateError"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ regenerateError }}
</VAlert>
<p class="text-body-2 mb-2">
Voer je authenticator code in ter bevestiging
</p>
<AppTextField
v-model="regenerateCode"
placeholder="123456"
autofocus
/>
</VCardText>
<VCardText v-else>
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
Nieuwe backup codes gegenereerd. Bewaar ze veilig.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in regeneratedCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyRegeneratedCodes"
>
Kopieer
</VBtn>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="showRegenerateDialog = false"
>
{{ regeneratedCodes.length > 0 ? 'Sluiten' : 'Annuleren' }}
</VBtn>
<VBtn
v-if="regeneratedCodes.length === 0"
color="primary"
:loading="regenerateCodesMutation.isPending.value"
:disabled="!regenerateCode"
@click="handleRegenerateBackupCodes"
>
Genereren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</VCol>
</VRow>
</template>

View File

@@ -84,7 +84,7 @@ async function handleLogin() {
authStore.setUser(data.data.user)
if (data.mfa_setup_required) {
router.replace('/account-settings/security')
router.replace('/account-settings?tab=security')
return
}

View File

@@ -45,9 +45,9 @@ export function setupGuards(router: Router) {
}
// MFA enforcement — redirect to security settings if MFA setup is required
if (authStore.mfaSetupRequired && to.path !== '/account-settings/security') {
if (authStore.mfaSetupRequired && to.path !== '/account-settings') {
if (import.meta.env.DEV) console.log('🔒 MFA setup required, redirecting to security settings')
return { path: '/account-settings/security' }
return { path: '/account-settings', query: { tab: 'security' } }
}
// Platform admin routes — require super_admin role

View File

@@ -29,7 +29,9 @@ export const useAuthStore = defineStore('auth', () => {
first_name: me.first_name,
last_name: me.last_name,
full_name: me.full_name,
date_of_birth: me.date_of_birth,
email: me.email,
phone: me.phone,
timezone: me.timezone,
locale: me.locale,
avatar: me.avatar,

View File

@@ -3,7 +3,9 @@ export interface User {
first_name: string
last_name: string
full_name: string
date_of_birth: string | null
email: string
phone: string | null
timezone: string
locale: string
avatar: string | null
@@ -28,7 +30,9 @@ export interface MeResponse {
first_name: string
last_name: string
full_name: string
date_of_birth: string | null
email: string
phone: string | null
timezone: string
locale: string
avatar: string | null

View File

@@ -78,7 +78,9 @@ export interface AcceptInvitationResponse {
first_name: string
last_name: string
full_name: string
date_of_birth: string | null
email: string
phone: string | null
timezone: string
locale: string
avatar: string | null

View File

@@ -21,7 +21,6 @@ declare module 'vue-router/auto-routes' {
'root': RouteRecordInfo<'root', '/', Record<never, never>, Record<never, never>>,
'$error': RouteRecordInfo<'$error', '/:error(.*)', { error: ParamValue<true> }, { error: ParamValue<false> }>,
'account-settings': RouteRecordInfo<'account-settings', '/account-settings', Record<never, never>, Record<never, never>>,
'account-settings-security': RouteRecordInfo<'account-settings-security', '/account-settings/security', Record<never, never>, Record<never, never>>,
'dashboard': RouteRecordInfo<'dashboard', '/dashboard', Record<never, never>, Record<never, never>>,
'events': RouteRecordInfo<'events', '/events', Record<never, never>, Record<never, never>>,
'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,

View File

@@ -52,9 +52,11 @@ declare global {
const extendRef: typeof import('@vueuse/core')['extendRef']
const formatDate: typeof import('./src/@core/utils/formatters')['formatDate']
const formatDateToMonthShort: typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']
const generateDeviceFingerprint: typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getDeviceName: typeof import('./src/utils/deviceFingerprint')['getDeviceName']
const h: typeof import('vue')['h']
const hexToRgb: typeof import('./src/@core/utils/colorConverter')['hexToRgb']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
@@ -417,9 +419,11 @@ declare module 'vue' {
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly formatDate: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDate']>
readonly formatDateToMonthShort: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']>
readonly generateDeviceFingerprint: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getDeviceName: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['getDeviceName']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hexToRgb: UnwrapRef<typeof import('./src/@core/utils/colorConverter')['hexToRgb']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>

File diff suppressed because it is too large Load Diff