- Restyle organizer auth pages: Dutch text, remove placeholder social login - Restyle portal auth pages to Vuexy v1 centered card pattern with decorative shapes - MFA challenge card component with VOtpInput, method tabs, backup code input, trusted device checkbox, and session countdown timer - Login pages handle mfa_required response with device fingerprint header - Security settings page with TOTP setup (QR code), email setup, disable MFA, backup codes regeneration, and trusted devices management - Portal profile page includes MFA security section - Admin user detail page shows MFA status with reset button - MFA enforcement route guard redirects to security settings when required - Device fingerprint utility for trusted device identification - MFA types, composables with TanStack Query for both apps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
500 lines
13 KiB
Vue
500 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
useAdminUser,
|
|
useUpdateAdminUser,
|
|
useStartImpersonation,
|
|
} from '@/composables/api/useAdmin'
|
|
import { useAdminResetMfa } from '@/composables/api/useMfa'
|
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
|
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
|
|
|
|
definePage({
|
|
meta: {
|
|
navActiveLink: 'platform-users',
|
|
},
|
|
})
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const impersonationStore = useImpersonationStore()
|
|
|
|
const userId = computed(() => String((route.params as { id: string }).id))
|
|
|
|
const activeTab = computed({
|
|
get: () => {
|
|
const tab = route.query.tab as string
|
|
return ['algemeen', 'organisaties'].includes(tab) ? tab : 'algemeen'
|
|
},
|
|
set: (value: string) => {
|
|
router.replace({ query: { ...route.query, tab: value } })
|
|
},
|
|
})
|
|
|
|
const { data: user, isLoading, isError, refetch } = useAdminUser(userId)
|
|
|
|
const roleColorMap: Record<string, string> = {
|
|
super_admin: 'error',
|
|
org_admin: 'purple',
|
|
org_member: 'info',
|
|
event_manager: 'cyan',
|
|
}
|
|
|
|
const platformRoleOptions = [
|
|
{ title: 'Super Admin', value: 'super_admin' },
|
|
]
|
|
|
|
// Edit dialog
|
|
const isEditDialogOpen = ref(false)
|
|
const editForm = ref<UpdateAdminUserPayload>({})
|
|
const { mutate: updateUser, isPending: isUpdating } = useUpdateAdminUser()
|
|
const showEditSuccess = ref(false)
|
|
|
|
function openEditDialog() {
|
|
if (!user.value) return
|
|
editForm.value = {
|
|
first_name: user.value.first_name,
|
|
last_name: user.value.last_name,
|
|
email: user.value.email,
|
|
timezone: user.value.timezone,
|
|
locale: user.value.locale,
|
|
roles: user.value.roles.filter(r => ['super_admin', 'support_agent'].includes(r)),
|
|
}
|
|
isEditDialogOpen.value = true
|
|
}
|
|
|
|
function submitEdit() {
|
|
updateUser(
|
|
{ id: userId.value, payload: editForm.value },
|
|
{
|
|
onSuccess: () => {
|
|
isEditDialogOpen.value = false
|
|
showEditSuccess.value = true
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
// Impersonation
|
|
const isImpersonateDialogOpen = ref(false)
|
|
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
|
|
|
function confirmImpersonate() {
|
|
startImpersonation(userId.value, {
|
|
onSuccess: (result) => {
|
|
isImpersonateDialogOpen.value = false
|
|
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
|
},
|
|
})
|
|
}
|
|
|
|
// MFA Reset
|
|
const isMfaResetDialogOpen = ref(false)
|
|
const { mutate: resetMfa, isPending: isResettingMfa } = useAdminResetMfa()
|
|
const showMfaResetSuccess = ref(false)
|
|
|
|
function confirmMfaReset() {
|
|
resetMfa(userId.value, {
|
|
onSuccess: () => {
|
|
isMfaResetDialogOpen.value = false
|
|
showMfaResetSuccess.value = true
|
|
refetch()
|
|
},
|
|
})
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('nl-NL', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function getInitials(name: string): string {
|
|
return name
|
|
.split(' ')
|
|
.map(p => p[0])
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.join('')
|
|
.toUpperCase()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Loading -->
|
|
<VSkeletonLoader
|
|
v-if="isLoading"
|
|
type="card, card"
|
|
/>
|
|
|
|
<!-- Error -->
|
|
<VAlert
|
|
v-else-if="isError"
|
|
type="error"
|
|
class="mb-4"
|
|
>
|
|
Kon gebruiker niet laden.
|
|
<template #append>
|
|
<VBtn
|
|
variant="text"
|
|
@click="refetch()"
|
|
>
|
|
Opnieuw proberen
|
|
</VBtn>
|
|
</template>
|
|
</VAlert>
|
|
|
|
<template v-else-if="user">
|
|
<!-- Header -->
|
|
<div class="d-flex align-center justify-space-between mb-2">
|
|
<div class="d-flex align-center gap-x-3">
|
|
<VBtn
|
|
icon="tabler-arrow-left"
|
|
variant="text"
|
|
size="small"
|
|
:to="{ name: 'platform-users' }"
|
|
/>
|
|
<VAvatar
|
|
v-if="user.avatar"
|
|
size="44"
|
|
:image="user.avatar"
|
|
/>
|
|
<VAvatar
|
|
v-else
|
|
size="44"
|
|
color="primary"
|
|
variant="tonal"
|
|
>
|
|
<span>{{ getInitials(user.full_name) }}</span>
|
|
</VAvatar>
|
|
<div>
|
|
<div class="d-flex align-center gap-x-2">
|
|
<h4 class="text-h4">
|
|
{{ user.full_name }}
|
|
</h4>
|
|
<VChip
|
|
v-for="role in user.roles"
|
|
:key="role"
|
|
:color="roleColorMap[role] ?? 'default'"
|
|
size="small"
|
|
>
|
|
{{ role }}
|
|
</VChip>
|
|
</div>
|
|
<span class="text-caption text-medium-emphasis">
|
|
{{ user.email }}
|
|
· Aangemaakt op {{ formatDate(user.created_at) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-x-2">
|
|
<VBtn
|
|
v-if="!user.is_super_admin"
|
|
variant="tonal"
|
|
color="warning"
|
|
prepend-icon="tabler-user-share"
|
|
@click="isImpersonateDialogOpen = true"
|
|
>
|
|
Inloggen als
|
|
</VBtn>
|
|
<VBtn
|
|
prepend-icon="tabler-edit"
|
|
@click="openEditDialog"
|
|
>
|
|
Bewerken
|
|
</VBtn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<VTabs
|
|
v-model="activeTab"
|
|
class="mb-6 mt-4"
|
|
>
|
|
<VTab
|
|
value="algemeen"
|
|
prepend-icon="tabler-user"
|
|
>
|
|
Algemeen
|
|
</VTab>
|
|
<VTab
|
|
value="organisaties"
|
|
prepend-icon="tabler-buildings"
|
|
>
|
|
Organisaties
|
|
</VTab>
|
|
</VTabs>
|
|
|
|
<VWindow
|
|
v-model="activeTab"
|
|
class="disable-tab-transition"
|
|
>
|
|
<!-- Algemeen tab -->
|
|
<VWindowItem value="algemeen">
|
|
<VCard>
|
|
<VCardTitle>Profiel</VCardTitle>
|
|
<VCardText>
|
|
<VRow>
|
|
<VCol cols="6">
|
|
<p class="text-body-2 text-disabled mb-1">
|
|
Tijdzone
|
|
</p>
|
|
<p class="text-body-1">
|
|
{{ user.timezone }}
|
|
</p>
|
|
</VCol>
|
|
<VCol cols="6">
|
|
<p class="text-body-2 text-disabled mb-1">
|
|
Taal
|
|
</p>
|
|
<p class="text-body-1">
|
|
{{ user.locale === 'nl' ? 'Nederlands' : 'English' }}
|
|
</p>
|
|
</VCol>
|
|
<VCol cols="6">
|
|
<p class="text-body-2 text-disabled mb-1">
|
|
E-mail geverifieerd
|
|
</p>
|
|
<p class="text-body-1">
|
|
<VIcon
|
|
:icon="user.email_verified_at ? 'tabler-circle-check' : 'tabler-circle-x'"
|
|
:color="user.email_verified_at ? 'success' : 'error'"
|
|
size="18"
|
|
class="me-1"
|
|
/>
|
|
{{ user.email_verified_at ? formatDate(user.email_verified_at) : 'Niet geverifieerd' }}
|
|
</p>
|
|
</VCol>
|
|
<VCol cols="6">
|
|
<p class="text-body-2 text-disabled mb-1">
|
|
Tweestapsverificatie
|
|
</p>
|
|
<p class="text-body-1">
|
|
<VChip
|
|
:color="user.mfa_enabled ? 'success' : 'default'"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ user.mfa_enabled ? 'Ingeschakeld' : 'Uitgeschakeld' }}
|
|
</VChip>
|
|
<VBtn
|
|
v-if="user.mfa_enabled"
|
|
variant="tonal"
|
|
color="error"
|
|
size="small"
|
|
class="ms-2"
|
|
@click="isMfaResetDialogOpen = true"
|
|
>
|
|
MFA resetten
|
|
</VBtn>
|
|
</p>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
</VCard>
|
|
</VWindowItem>
|
|
|
|
<!-- Organisaties tab -->
|
|
<VWindowItem value="organisaties">
|
|
<VCard>
|
|
<VCardText
|
|
v-if="user.organisations.length === 0"
|
|
class="text-center text-disabled"
|
|
>
|
|
Geen organisaties
|
|
</VCardText>
|
|
<VList
|
|
v-else
|
|
lines="one"
|
|
>
|
|
<VListItem
|
|
v-for="org in user.organisations"
|
|
:key="org.id"
|
|
:to="{ name: 'platform-organisations-id', params: { id: org.id } }"
|
|
>
|
|
<VListItemTitle>{{ org.name }}</VListItemTitle>
|
|
<template #append>
|
|
<VChip
|
|
:color="roleColorMap[org.role] ?? 'default'"
|
|
size="x-small"
|
|
>
|
|
{{ org.role }}
|
|
</VChip>
|
|
</template>
|
|
</VListItem>
|
|
</VList>
|
|
</VCard>
|
|
</VWindowItem>
|
|
</VWindow>
|
|
</template>
|
|
|
|
<!-- Edit Dialog -->
|
|
<VDialog
|
|
v-model="isEditDialogOpen"
|
|
max-width="500"
|
|
>
|
|
<VCard title="Gebruiker bewerken">
|
|
<VCardText>
|
|
<VRow>
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<AppTextField
|
|
v-model="editForm.first_name"
|
|
label="Voornaam"
|
|
/>
|
|
</VCol>
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<AppTextField
|
|
v-model="editForm.last_name"
|
|
label="Achternaam"
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<AppTextField
|
|
v-model="editForm.email"
|
|
label="E-mail"
|
|
type="email"
|
|
/>
|
|
</VCol>
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<AppTextField
|
|
v-model="editForm.timezone"
|
|
label="Tijdzone"
|
|
/>
|
|
</VCol>
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<AppSelect
|
|
v-model="editForm.locale"
|
|
:items="[{ title: 'Nederlands', value: 'nl' }, { title: 'English', value: 'en' }]"
|
|
label="Taal"
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<AppSelect
|
|
v-model="editForm.roles"
|
|
:items="platformRoleOptions"
|
|
label="Platform rollen"
|
|
multiple
|
|
chips
|
|
closable-chips
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
<VCardActions>
|
|
<VSpacer />
|
|
<VBtn
|
|
variant="tonal"
|
|
@click="isEditDialogOpen = false"
|
|
>
|
|
Annuleren
|
|
</VBtn>
|
|
<VBtn
|
|
color="primary"
|
|
:loading="isUpdating"
|
|
@click="submitEdit"
|
|
>
|
|
Opslaan
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- Impersonate Dialog -->
|
|
<VDialog
|
|
v-model="isImpersonateDialogOpen"
|
|
max-width="400"
|
|
>
|
|
<VCard title="Inloggen als gebruiker">
|
|
<VCardText>
|
|
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
|
|
({{ user?.email }}). Wil je doorgaan?
|
|
</VCardText>
|
|
<VCardActions>
|
|
<VSpacer />
|
|
<VBtn
|
|
variant="text"
|
|
@click="isImpersonateDialogOpen = false"
|
|
>
|
|
Annuleren
|
|
</VBtn>
|
|
<VBtn
|
|
color="warning"
|
|
:loading="isImpersonating"
|
|
@click="confirmImpersonate"
|
|
>
|
|
Doorgaan
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- MFA Reset Dialog -->
|
|
<VDialog
|
|
v-model="isMfaResetDialogOpen"
|
|
max-width="460"
|
|
>
|
|
<VCard title="MFA resetten">
|
|
<VCardText>
|
|
<VAlert
|
|
type="warning"
|
|
variant="tonal"
|
|
class="mb-4"
|
|
>
|
|
Weet je zeker dat je de tweestapsverificatie van <strong>{{ user?.full_name }}</strong> wilt uitschakelen?
|
|
De gebruiker moet MFA opnieuw instellen bij de volgende login.
|
|
</VAlert>
|
|
</VCardText>
|
|
<VCardActions>
|
|
<VSpacer />
|
|
<VBtn
|
|
variant="tonal"
|
|
@click="isMfaResetDialogOpen = false"
|
|
>
|
|
Annuleren
|
|
</VBtn>
|
|
<VBtn
|
|
color="error"
|
|
:loading="isResettingMfa"
|
|
@click="confirmMfaReset"
|
|
>
|
|
MFA resetten
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- Success snackbar -->
|
|
<VSnackbar
|
|
v-model="showEditSuccess"
|
|
color="success"
|
|
:timeout="3000"
|
|
>
|
|
Gebruiker bijgewerkt
|
|
</VSnackbar>
|
|
|
|
<VSnackbar
|
|
v-model="showMfaResetSuccess"
|
|
color="success"
|
|
:timeout="3000"
|
|
>
|
|
MFA is gereset voor deze gebruiker
|
|
</VSnackbar>
|
|
</div>
|
|
</template>
|