Rewrites plugins/1.router/guards.ts per ARCH-CONSOLIDATION §4.3. The
B1 portal-context carve-out is removed; portal/organizer routing is
now declarative via meta.context, role gates via meta.requiresRole.
Guard pipeline:
1. Initialize auth store on first navigation
2. Public routes pass through (authenticated user on guest-only path
is bounced to resolveLandingRoute)
3. Auth required → /login?to=<path>
4. MFA setup gate → /account-settings?tab=security
5. requiresRole declarative check (replaces hardcoded /platform path
prefix + isSuperAdmin)
6. Context routing — portal returns early, organizer falls through
and sets lastContext
7. Org-selection check (organizer routes only)
Page meta updates (mechanical, idempotent):
- 4 portal pages: removed `requiresAuth: true` (auth is implicit)
- 4 pages: replaced `requiresAuth: false` with `meta.public: true`
(registreren, wachtwoord-instellen, advance/[token],
invitations/[token])
- 22 organizer pages: added `context: 'organizer'`
(account-settings, events/**, organisation/form-failures/**,
select-organisation, dashboard, events/index, members,
organisation/{index,companies,settings})
- 8 platform pages: added `context: 'organizer'` +
`requiresRole: 'super_admin'`
- 6 organizer pages had no definePage block — one was added with
`context: 'organizer'`
Adds plugins/1.router/__tests__/guards.spec.ts (11 tests) covering
public passthrough, unauthenticated redirect, portal/organizer
context branching, declarative requiresRole, org-selection
redirect, MFA gate.
Test count 178 → 189 (11 new). Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
530 lines
15 KiB
Vue
530 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { useMyOrganisation, useOrganisationDashboardStats } from '@/composables/api/useOrganisations'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
|
|
import type { ActivityLogEntry, Organisation } from '@/types/organisation'
|
|
|
|
definePage({
|
|
meta: {
|
|
context: 'organizer',
|
|
},
|
|
})
|
|
|
|
const authStore = useAuthStore()
|
|
const router = useRouter()
|
|
|
|
const { data: organisation, isLoading: orgLoading, isError: orgError, refetch: refetchOrg } = useMyOrganisation()
|
|
|
|
const orgId = computed(() => organisation.value?.id ?? '')
|
|
|
|
const {
|
|
data: stats,
|
|
isLoading: statsLoading,
|
|
isError: statsError,
|
|
refetch: refetchStats,
|
|
} = useOrganisationDashboardStats(orgId)
|
|
|
|
const isOrgAdmin = computed(() => {
|
|
const role = authStore.currentOrganisation?.role
|
|
|
|
return role === 'org_admin' || authStore.isSuperAdmin
|
|
})
|
|
|
|
const statusColor: Record<Organisation['billing_status'], string> = {
|
|
trial: 'info',
|
|
active: 'success',
|
|
suspended: 'warning',
|
|
cancelled: 'error',
|
|
}
|
|
|
|
const isEditDialogOpen = ref(false)
|
|
|
|
const activeSubtitle = computed(() => {
|
|
const active = stats.value?.active_events_count ?? 0
|
|
if (active > 0)
|
|
return `${active} actief`
|
|
|
|
return 'Nog geen actieve evenementen'
|
|
})
|
|
|
|
const roleColor: Record<string, string> = {
|
|
org_admin: 'primary',
|
|
org_member: 'secondary',
|
|
}
|
|
|
|
const roleLabel: Record<string, string> = {
|
|
org_admin: 'Admin',
|
|
org_member: 'Lid',
|
|
}
|
|
|
|
function goToMembers() {
|
|
router.push('/members')
|
|
}
|
|
|
|
function goToEvents() {
|
|
router.push('/events')
|
|
}
|
|
|
|
function formatRelativeTime(iso: string | null): string {
|
|
if (!iso)
|
|
return ''
|
|
const date = new Date(iso)
|
|
const diffMs = Date.now() - date.getTime()
|
|
const diffSec = Math.round(diffMs / 1000)
|
|
if (diffSec < 60)
|
|
return 'zojuist'
|
|
const diffMin = Math.round(diffSec / 60)
|
|
if (diffMin < 60)
|
|
return `${diffMin} min geleden`
|
|
const diffHr = Math.round(diffMin / 60)
|
|
if (diffHr < 24)
|
|
return `${diffHr} uur geleden`
|
|
const diffDays = Math.round(diffHr / 24)
|
|
if (diffDays < 7)
|
|
return `${diffDays} dag${diffDays === 1 ? '' : 'en'} geleden`
|
|
|
|
return date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' })
|
|
}
|
|
|
|
function avatarInitials(name: string | null | undefined): string {
|
|
if (!name)
|
|
return '?'
|
|
|
|
return name
|
|
.split(' ')
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map(part => part[0]?.toUpperCase() ?? '')
|
|
.join('')
|
|
}
|
|
|
|
function describeActivity(entry: ActivityLogEntry): string {
|
|
const changed = Object.keys(entry.properties?.attributes ?? {})
|
|
|
|
const labelMap: Record<string, string> = {
|
|
name: 'naam',
|
|
slug: 'slug',
|
|
contact_name: 'contactpersoon',
|
|
contact_email: 'contact e-mail',
|
|
phone: 'telefoonnummer',
|
|
website: 'website',
|
|
}
|
|
|
|
if (changed.length === 0)
|
|
return entry.description
|
|
const fields = changed.map(f => labelMap[f] ?? f).join(', ')
|
|
|
|
return `Wijzigde ${fields}`
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Loading -->
|
|
<VSkeletonLoader
|
|
v-if="orgLoading"
|
|
type="article, article"
|
|
/>
|
|
|
|
<!-- Error -->
|
|
<VAlert
|
|
v-else-if="orgError"
|
|
type="error"
|
|
class="mb-4"
|
|
>
|
|
Kon organisatie niet laden.
|
|
<template #append>
|
|
<VBtn
|
|
variant="text"
|
|
@click="refetchOrg"
|
|
>
|
|
Opnieuw proberen
|
|
</VBtn>
|
|
</template>
|
|
</VAlert>
|
|
|
|
<template v-else-if="organisation">
|
|
<!-- Header -->
|
|
<div class="d-flex align-center justify-space-between flex-wrap gap-4 mb-6">
|
|
<div>
|
|
<div class="d-flex align-center gap-x-2 flex-wrap">
|
|
<h4 class="text-h4 mb-0">
|
|
{{ organisation.name }}
|
|
</h4>
|
|
<VChip
|
|
:color="statusColor[organisation.billing_status]"
|
|
size="small"
|
|
>
|
|
{{ organisation.billing_status.charAt(0).toUpperCase() + organisation.billing_status.slice(1) }}
|
|
</VChip>
|
|
</div>
|
|
<code class="text-caption text-primary d-block mt-1">{{ organisation.slug }}</code>
|
|
</div>
|
|
<VBtn
|
|
v-if="isOrgAdmin"
|
|
prepend-icon="tabler-edit"
|
|
@click="isEditDialogOpen = true"
|
|
>
|
|
Organisatie bewerken
|
|
</VBtn>
|
|
</div>
|
|
|
|
<!-- Stat cards -->
|
|
<VRow
|
|
v-if="statsLoading"
|
|
align="stretch"
|
|
class="mb-6"
|
|
>
|
|
<VCol
|
|
v-for="n in 3"
|
|
:key="n"
|
|
cols="12"
|
|
md="4"
|
|
class="d-flex"
|
|
>
|
|
<VCard class="flex-grow-1 w-100">
|
|
<VCardText>
|
|
<VSkeletonLoader type="heading" />
|
|
</VCardText>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<VAlert
|
|
v-else-if="statsError"
|
|
type="error"
|
|
variant="tonal"
|
|
class="mb-6"
|
|
>
|
|
Kon statistieken niet laden.
|
|
<template #append>
|
|
<VBtn
|
|
variant="text"
|
|
size="small"
|
|
@click="refetchStats"
|
|
>
|
|
Opnieuw proberen
|
|
</VBtn>
|
|
</template>
|
|
</VAlert>
|
|
|
|
<VRow
|
|
v-else-if="stats"
|
|
align="stretch"
|
|
class="mb-2"
|
|
>
|
|
<VCol
|
|
cols="12"
|
|
md="4"
|
|
class="d-flex"
|
|
>
|
|
<AppKpiCard
|
|
icon="tabler-users"
|
|
icon-color="primary"
|
|
:value="stats.members_count"
|
|
title="Leden"
|
|
subtitle="Actieve leden in je organisatie"
|
|
clickable
|
|
@click="goToMembers"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol
|
|
cols="12"
|
|
md="4"
|
|
class="d-flex"
|
|
>
|
|
<AppKpiCard
|
|
icon="tabler-calendar-event"
|
|
icon-color="primary"
|
|
:value="stats.events_count"
|
|
title="Evenementen"
|
|
:subtitle="activeSubtitle"
|
|
clickable
|
|
@click="goToEvents"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol
|
|
cols="12"
|
|
md="4"
|
|
class="d-flex"
|
|
>
|
|
<AppKpiCard
|
|
icon="tabler-user-circle"
|
|
icon-color="primary"
|
|
:value="stats.persons_count"
|
|
title="Personen"
|
|
subtitle="Over alle evenementen heen"
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<!-- Info cards row -->
|
|
<VRow>
|
|
<!-- Organisation details -->
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<VCard>
|
|
<VCardItem>
|
|
<template #title>
|
|
Organisatiegegevens
|
|
</template>
|
|
<template
|
|
v-if="isOrgAdmin"
|
|
#append
|
|
>
|
|
<VBtn
|
|
variant="text"
|
|
size="small"
|
|
icon="tabler-edit"
|
|
@click="isEditDialogOpen = true"
|
|
/>
|
|
</template>
|
|
</VCardItem>
|
|
<VDivider />
|
|
<VList
|
|
lines="two"
|
|
density="comfortable"
|
|
>
|
|
<VListItem>
|
|
<template #title>
|
|
<span class="text-medium-emphasis text-caption">Naam</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<span class="text-body-1">{{ organisation.name }}</span>
|
|
</template>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #title>
|
|
<span class="text-medium-emphasis text-caption">Slug</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<code class="text-body-2 text-primary">{{ organisation.slug }}</code>
|
|
</template>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #title>
|
|
<span class="text-medium-emphasis text-caption">Contactpersoon</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<span class="text-body-1">{{ organisation.contact_name || '—' }}</span>
|
|
</template>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #title>
|
|
<span class="text-medium-emphasis text-caption">Contact e-mail</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<a
|
|
v-if="organisation.contact_email"
|
|
:href="`mailto:${organisation.contact_email}`"
|
|
class="text-body-1 text-primary"
|
|
>{{ organisation.contact_email }}</a>
|
|
<span
|
|
v-else
|
|
class="text-body-1"
|
|
>—</span>
|
|
</template>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #title>
|
|
<span class="text-medium-emphasis text-caption">Telefoon</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<span class="text-body-1">{{ organisation.phone || '—' }}</span>
|
|
</template>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #title>
|
|
<span class="text-medium-emphasis text-caption">Website</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<a
|
|
v-if="organisation.website"
|
|
:href="organisation.website"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-body-1 text-primary"
|
|
>{{ organisation.website }}</a>
|
|
<span
|
|
v-else
|
|
class="text-body-1"
|
|
>—</span>
|
|
</template>
|
|
</VListItem>
|
|
</VList>
|
|
</VCard>
|
|
</VCol>
|
|
|
|
<!-- Members card -->
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<VCard class="h-100">
|
|
<VCardItem>
|
|
<template #title>
|
|
<span>Leden</span>
|
|
<span
|
|
v-if="stats"
|
|
class="text-medium-emphasis text-body-2 ms-2"
|
|
>({{ stats.members_count }})</span>
|
|
</template>
|
|
<template #append>
|
|
<VBtn
|
|
variant="text"
|
|
size="small"
|
|
append-icon="tabler-arrow-right"
|
|
@click="goToMembers"
|
|
>
|
|
Alle leden
|
|
</VBtn>
|
|
</template>
|
|
</VCardItem>
|
|
<VDivider />
|
|
|
|
<VSkeletonLoader
|
|
v-if="statsLoading"
|
|
type="list-item-avatar@5"
|
|
/>
|
|
<VAlert
|
|
v-else-if="statsError"
|
|
type="error"
|
|
variant="tonal"
|
|
class="ma-4"
|
|
>
|
|
Kon leden niet laden.
|
|
</VAlert>
|
|
<div
|
|
v-else-if="stats && stats.top_members.length === 0"
|
|
class="pa-6 text-center text-medium-emphasis"
|
|
>
|
|
Nog geen leden
|
|
</div>
|
|
<VList
|
|
v-else-if="stats"
|
|
lines="two"
|
|
density="comfortable"
|
|
>
|
|
<VListItem
|
|
v-for="member in stats.top_members"
|
|
:key="member.id"
|
|
>
|
|
<template #prepend>
|
|
<VAvatar
|
|
:image="member.avatar_url ?? undefined"
|
|
color="primary"
|
|
variant="tonal"
|
|
size="36"
|
|
>
|
|
<span
|
|
v-if="!member.avatar_url"
|
|
class="text-sm"
|
|
>{{ avatarInitials(member.name) }}</span>
|
|
</VAvatar>
|
|
</template>
|
|
<template #title>
|
|
{{ member.name }}
|
|
</template>
|
|
<template #subtitle>
|
|
<span class="text-body-2 text-medium-emphasis">{{ member.email }}</span>
|
|
</template>
|
|
<template #append>
|
|
<VChip
|
|
:color="roleColor[member.role] ?? 'secondary'"
|
|
size="small"
|
|
>
|
|
{{ roleLabel[member.role] ?? member.role }}
|
|
</VChip>
|
|
</template>
|
|
</VListItem>
|
|
</VList>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<!-- Activity log -->
|
|
<VRow class="mt-2">
|
|
<VCol cols="12">
|
|
<VCard>
|
|
<VCardItem>
|
|
<template #title>
|
|
Recente activiteit
|
|
</template>
|
|
</VCardItem>
|
|
<VDivider />
|
|
|
|
<VSkeletonLoader
|
|
v-if="statsLoading"
|
|
type="list-item-avatar@5"
|
|
/>
|
|
<VAlert
|
|
v-else-if="statsError"
|
|
type="error"
|
|
variant="tonal"
|
|
class="ma-4"
|
|
>
|
|
Kon activiteiten niet laden.
|
|
<template #append>
|
|
<VBtn
|
|
variant="text"
|
|
size="small"
|
|
@click="refetchStats"
|
|
>
|
|
Opnieuw proberen
|
|
</VBtn>
|
|
</template>
|
|
</VAlert>
|
|
<div
|
|
v-else-if="stats && stats.recent_activity.length === 0"
|
|
class="pa-6 text-center text-medium-emphasis"
|
|
>
|
|
Nog geen recente activiteit
|
|
</div>
|
|
<VList
|
|
v-else-if="stats"
|
|
lines="two"
|
|
density="comfortable"
|
|
>
|
|
<VListItem
|
|
v-for="entry in stats.recent_activity"
|
|
:key="entry.id"
|
|
>
|
|
<template #prepend>
|
|
<VAvatar
|
|
:image="entry.causer_avatar_url ?? undefined"
|
|
color="primary"
|
|
variant="tonal"
|
|
size="36"
|
|
>
|
|
<span
|
|
v-if="!entry.causer_avatar_url"
|
|
class="text-sm"
|
|
>{{ avatarInitials(entry.causer_name) }}</span>
|
|
</VAvatar>
|
|
</template>
|
|
<template #title>
|
|
<span class="text-body-1">{{ describeActivity(entry) }}</span>
|
|
</template>
|
|
<template #subtitle>
|
|
<span class="text-body-2 text-medium-emphasis">
|
|
{{ entry.causer_name ?? 'Systeem' }} · {{ formatRelativeTime(entry.created_at) }}
|
|
</span>
|
|
</template>
|
|
</VListItem>
|
|
</VList>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<EditOrganisationDialog
|
|
v-model="isEditDialogOpen"
|
|
:organisation="organisation"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</template>
|