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:
3
apps/app/components.d.ts
vendored
3
apps/app/components.d.ts
vendored
@@ -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']
|
||||
|
||||
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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
· 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
apps/app/typed-router.d.ts
vendored
1
apps/app/typed-router.d.ts
vendored
@@ -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> }>,
|
||||
|
||||
Reference in New Issue
Block a user