Files
crewli/apps/app/src/pages/organisation/index.vue
bert.hausmans 473b22ac9e feat(router): context-aware guards with meta-driven role/context resolution
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>
2026-05-05 21:32:54 +02:00

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>