Browse Source

Enhance Role-Based Visibility and Navigation Logic

- Introduced role visibility checks in AreaTabs.vue to filter displayed areas based on the user's active role, improving user experience and accessibility.
- Updated RoleSwitcher.vue to enhance accessibility with a high-contrast checkmark for better visibility.
- Modified useActiveRole.ts to streamline role initialization and refresh logic for role-based product visibility.
- Added explicit keys for role-based data fetching in product-related pages to ensure accurate data refresh.
- Enhanced API endpoint for product retrieval to return 404 if a product is not accessible based on the user's role, ensuring security and clarity.
main
Bastian Masanek 1 month ago
parent
commit
dcd96ffb68
  1. 36
      app/components/navigation/AreaTabs.vue
  2. 11
      app/components/navigation/RoleSwitcher.vue
  3. 24
      app/composables/useActiveRole.ts
  4. 1
      app/pages/educator/index.vue
  5. 1
      app/pages/experimenta/index.vue
  6. 19
      app/pages/products/[id].vue
  7. 1
      app/pages/products/index.vue
  8. 31
      server/api/products/[id].get.ts

36
app/components/navigation/AreaTabs.vue

@ -3,6 +3,8 @@ import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'luc
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
type RoleCode = 'private' | 'educator' | 'company'
interface ProductArea {
id: string
label: string
@ -11,6 +13,7 @@ interface ProductArea {
visible: boolean
badge?: string
route: string
roleVisibility?: 'all' | RoleCode[]
}
const areas: ProductArea[] = [
@ -21,6 +24,7 @@ const areas: ProductArea[] = [
enabled: true,
visible: true,
route: '/',
roleVisibility: 'all',
},
{
id: 'makerspace',
@ -29,6 +33,7 @@ const areas: ProductArea[] = [
enabled: true,
visible: true,
route: '/products',
roleVisibility: 'all',
},
{
id: 'educator',
@ -38,6 +43,7 @@ const areas: ProductArea[] = [
visible: true,
badge: 'Demnächst',
route: '/educator',
roleVisibility: ['educator'],
},
{
id: 'experimenta',
@ -47,6 +53,7 @@ const areas: ProductArea[] = [
visible: true,
badge: 'Demnächst',
route: '/experimenta',
roleVisibility: 'all',
},
{
id: 'labs',
@ -56,22 +63,41 @@ const areas: ProductArea[] = [
visible: false,
badge: 'Demnächst',
route: '/labs',
roleVisibility: ['educator', 'company'],
},
]
const route = useRoute()
const { activeRole } = useActiveRole()
// Filter areas by role visibility
const visibleAreas = computed(() => {
return areas.filter(area => {
// Legacy visible flag check
if (!area.visible) return false
// No role requirement = visible to all (backward compatible)
if (!area.roleVisibility) return true
// Explicitly set to 'all'
if (area.roleVisibility === 'all') return true
// Check if user's active role matches
return area.roleVisibility.includes(activeRole.value as RoleCode)
})
})
const currentArea = computed(() => {
// Determine current area based on route - check areas array dynamically
// Determine current area based on route - check visibleAreas array dynamically
const currentPath = route.path
// Exact match for root path
if (currentPath === '/') {
return areas.find(area => area.route === '/')?.id || ''
return visibleAreas.value.find(area => area.route === '/')?.id || ''
}
// Find area where route path starts with area.route
const matchedArea = areas.find(area =>
const matchedArea = visibleAreas.value.find(area =>
area.route !== '/' && currentPath.startsWith(area.route)
)
@ -90,7 +116,7 @@ function navigateToArea(area: ProductArea) {
<!-- Desktop: Tabs -->
<Tabs :model-value="currentArea" class="hidden md:block">
<TabsList class="h-auto p-2 bg-white/5">
<TabsTrigger v-for="area in areas.filter(area => area.visible)" :key="area.id" :value="area.id"
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :value="area.id"
:disabled="!area.enabled" :class="[
'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md',
!area.enabled && 'opacity-50 cursor-not-allowed',
@ -112,7 +138,7 @@ function navigateToArea(area: ProductArea) {
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
<div class="md:hidden overflow-x-auto scrollbar-hide">
<div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max">
<button v-for="area in areas.filter(area => area.visible)" :key="area.id" :disabled="!area.enabled" :class="[
<button v-for="area in visibleAreas" :key="area.id" :disabled="!area.enabled" :class="[
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0',
currentArea === area.id
? 'bg-accent text-white shadow-md'

11
app/components/navigation/RoleSwitcher.vue

@ -83,7 +83,7 @@ async function handleRoleSwitch(roleCode: string, hasRole: boolean) {
</Alert>
<DropdownMenuLabel class="text-sm font-normal text-muted-foreground py-3">
Ich kaufe als...
Du kaufst als...
</DropdownMenuLabel>
<DropdownMenuSeparator />
@ -103,8 +103,13 @@ async function handleRoleSwitch(roleCode: string, hasRole: boolean) {
</span>
</div>
<!-- Checkmark if active -->
<Check v-if="activeRole === role.code && role.hasRole" class="h-5 w-5 text-success flex-shrink-0" />
<!-- High-contrast checkmark for colorblind accessibility -->
<div v-if="activeRole === role.code && role.hasRole" class="relative flex-shrink-0">
<!-- Background circle for contrast -->
<div class="absolute inset-0 bg-white dark:bg-gray-900 rounded-full opacity-90"></div>
<!-- Checkmark icon with high contrast -->
<Check class="h-5 w-5 text-gray-900 dark:text-white relative z-10" />
</div>
<!-- Badge if not approved -->
<Badge v-if="!role.hasRole" variant="secondary" class="text-xs px-2 py-0.5 flex-shrink-0">

24
app/composables/useActiveRole.ts

@ -36,7 +36,6 @@ export function useActiveRole() {
const roleChangedByAdmin = useState<boolean>('roleChangedByAdmin', () => false)
const loading = useState<boolean>('roleLoading', () => false)
const error = useState<string | null>('roleError', () => null)
const initialized = useState<boolean>('roleInitialized', () => false)
/**
* Fetch current role status from server
@ -88,8 +87,13 @@ export function useActiveRole() {
activeRole.value = roleCode
roleChangedByAdmin.value = false
// Refresh products if on products page
await refreshNuxtData('products')
// Refresh all product list pages (role-based visibility)
// Note: Product detail pages will handle visibility via API 404 check
await Promise.all([
refreshNuxtData('products-list'), // Main products page
refreshNuxtData('educator-products'), // Educator products page
refreshNuxtData('experimenta-products'), // Experimenta products page
])
return true
} catch (err: any) {
@ -112,17 +116,13 @@ export function useActiveRole() {
const hasMultipleRoles = computed(() => approvedRoles.value.length > 1)
/**
* Auto-initialize on first use (fetch role status from server)
* This ensures the role is correct immediately after login
* Auto-fetch roles when user logs in
* This ensures the role button shows the correct role immediately after login
* Uses callOnce to prevent redundant API calls
*/
const { loggedIn } = useUserSession()
if (!initialized.value && loggedIn.value) {
initialized.value = true
// Fetch initial role status (don't await to avoid blocking)
fetchRoleStatus().catch((err) => {
// Silent fail - user can still use the app with default role
console.warn('Failed to initialize role status:', err)
})
if (loggedIn.value) {
callOnce('init-roles', () => fetchRoleStatus())
}
return {

1
app/pages/educator/index.vue

@ -27,6 +27,7 @@ interface Product {
// Fetch products from API - only educator passes
const { data: products, error, pending } = await useFetch<Product[]>('/api/products', {
key: 'educator-products', // Explicit key for role-based refresh
query: {
category: 'educator-annual-pass',
},

1
app/pages/experimenta/index.vue

@ -27,6 +27,7 @@ interface Product {
// Fetch products from API - only experimenta passes
const { data: products, error, pending } = await useFetch<Product[]>('/api/products', {
key: 'experimenta-products', // Explicit key for role-based refresh
query: {
category: 'annual-pass',
},

19
app/pages/products/[id].vue

@ -33,7 +33,24 @@ interface Product {
}
// Fetch product from API
const { data: product, error, pending } = await useFetch<Product>(`/api/products/${productId}`)
const { data: product, error, pending } = await useFetch<Product>(`/api/products/${productId}`, {
key: `product-${productId}`, // Explicit key for role-based refresh
})
// Auto-redirect to products list if 404 (e.g., after role switch)
watch(error, (newError) => {
if (newError?.statusCode === 404) {
// Show notification
toast.error('Produkt nicht verfügbar', {
description: 'Dieses Produkt ist für deine aktuelle Rolle nicht sichtbar.',
})
// Redirect after short delay
setTimeout(() => {
navigateTo('/products')
}, 1500)
}
})
// Format price in EUR
const formattedPrice = computed(() => {

1
app/pages/products/index.vue

@ -27,6 +27,7 @@ interface Product {
// Fetch products from API - only Makerspace and Educator passes
const { data: products, error, pending } = await useFetch<Product[]>('/api/products', {
key: 'products-list', // Explicit key for role-based refresh
query: {
category: 'makerspace-annual-pass,educator-annual-pass',
},

31
server/api/products/[id].get.ts

@ -2,12 +2,18 @@
* GET /api/products/[id]
*
* Returns a single product by UUID.
* Returns 404 if product is not found or is inactive.
* Returns 404 if product is not found, is inactive, or user's role doesn't have access.
*
* Role-based Visibility (MVP):
* - Unauthenticated users: 404
* - Authenticated users: Only see products assigned to their ACTIVE role
*/
import { z } from 'zod'
import { and, eq } from 'drizzle-orm'
import { products } from '../../database/schema'
import { getVisibleProductIdsForRole } from '../../utils/roles'
import { getUserActiveRole } from '../../utils/role-session'
// UUID validation schema
const paramsSchema = z.object({
@ -21,6 +27,29 @@ export default defineEventHandler(async (event) => {
const params = await getValidatedRouterParams(event, paramsSchema.parse)
try {
// Get user session (MVP: unauthenticated users cannot access products)
const { user } = await getUserSession(event)
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found',
})
}
// Get user's active role
const activeRole = await getUserActiveRole(event)
// Check role-based visibility
const visibleProductIds = await getVisibleProductIdsForRole(user.id, activeRole)
// Return 404 if product is not visible to user's role
if (!visibleProductIds.includes(params.id)) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found',
})
}
// Fetch product by ID (must be active)
const product = await db.query.products.findFirst({
where: and(eq(products.id, params.id), eq(products.active, true)),

Loading…
Cancel
Save