Files
crewli/apps/app/src/pages/platform/users/[id].vue
bert.hausmans 0be2956ea4 feat: MFA frontend with auth page restyling, challenge screen, and setup wizard
- 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>
2026-04-15 21:32:17 +02:00

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 }}
&middot; 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>