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:
442
apps/app/src/components/account-settings/AccountTab.vue
Normal file
442
apps/app/src/components/account-settings/AccountTab.vue
Normal 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>
|
||||
@@ -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>
|
||||
639
apps/app/src/components/account-settings/SecurityTab.vue
Normal file
639
apps/app/src/components/account-settings/SecurityTab.vue
Normal 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">
|
||||
· 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>
|
||||
Reference in New Issue
Block a user