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 2 months 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
type RoleCode = 'private' | 'educator' | 'company'
interface ProductArea { interface ProductArea {
id: string id: string
label: string label: string
@ -11,6 +13,7 @@ interface ProductArea {
visible: boolean visible: boolean
badge?: string badge?: string
route: string route: string
roleVisibility?: 'all' | RoleCode[]
} }
const areas: ProductArea[] = [ const areas: ProductArea[] = [
@ -21,6 +24,7 @@ const areas: ProductArea[] = [
enabled: true, enabled: true,
visible: true, visible: true,
route: '/', route: '/',
roleVisibility: 'all',
}, },
{ {
id: 'makerspace', id: 'makerspace',
@ -29,6 +33,7 @@ const areas: ProductArea[] = [
enabled: true, enabled: true,
visible: true, visible: true,
route: '/products', route: '/products',
roleVisibility: 'all',
}, },
{ {
id: 'educator', id: 'educator',
@ -38,6 +43,7 @@ const areas: ProductArea[] = [
visible: true, visible: true,
badge: 'Demnächst', badge: 'Demnächst',
route: '/educator', route: '/educator',
roleVisibility: ['educator'],
}, },
{ {
id: 'experimenta', id: 'experimenta',
@ -47,6 +53,7 @@ const areas: ProductArea[] = [
visible: true, visible: true,
badge: 'Demnächst', badge: 'Demnächst',
route: '/experimenta', route: '/experimenta',
roleVisibility: 'all',
}, },
{ {
id: 'labs', id: 'labs',
@ -56,22 +63,41 @@ const areas: ProductArea[] = [
visible: false, visible: false,
badge: 'Demnächst', badge: 'Demnächst',
route: '/labs', route: '/labs',
roleVisibility: ['educator', 'company'],
}, },
] ]
const route = useRoute() 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(() => { 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 const currentPath = route.path
// Exact match for root path // Exact match for root path
if (currentPath === '/') { 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 // 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) area.route !== '/' && currentPath.startsWith(area.route)
) )
@ -90,7 +116,7 @@ function navigateToArea(area: ProductArea) {
<!-- Desktop: Tabs --> <!-- Desktop: Tabs -->
<Tabs :model-value="currentArea" class="hidden md:block"> <Tabs :model-value="currentArea" class="hidden md:block">
<TabsList class="h-auto p-2 bg-white/5"> <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="[ :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', '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', !area.enabled && 'opacity-50 cursor-not-allowed',
@ -112,7 +138,7 @@ function navigateToArea(area: ProductArea) {
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) --> <!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
<div class="md:hidden overflow-x-auto scrollbar-hide"> <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"> <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', '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 currentArea === area.id
? 'bg-accent text-white shadow-md' ? 'bg-accent text-white shadow-md'

11
app/components/navigation/RoleSwitcher.vue

@ -83,7 +83,7 @@ async function handleRoleSwitch(roleCode: string, hasRole: boolean) {
</Alert> </Alert>
<DropdownMenuLabel class="text-sm font-normal text-muted-foreground py-3"> <DropdownMenuLabel class="text-sm font-normal text-muted-foreground py-3">
Ich kaufe als... Du kaufst als...
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -103,8 +103,13 @@ async function handleRoleSwitch(roleCode: string, hasRole: boolean) {
</span> </span>
</div> </div>
<!-- Checkmark if active --> <!-- High-contrast checkmark for colorblind accessibility -->
<Check v-if="activeRole === role.code && role.hasRole" class="h-5 w-5 text-success flex-shrink-0" /> <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 if not approved -->
<Badge v-if="!role.hasRole" variant="secondary" class="text-xs px-2 py-0.5 flex-shrink-0"> <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 roleChangedByAdmin = useState<boolean>('roleChangedByAdmin', () => false)
const loading = useState<boolean>('roleLoading', () => false) const loading = useState<boolean>('roleLoading', () => false)
const error = useState<string | null>('roleError', () => null) const error = useState<string | null>('roleError', () => null)
const initialized = useState<boolean>('roleInitialized', () => false)
/** /**
* Fetch current role status from server * Fetch current role status from server
@ -88,8 +87,13 @@ export function useActiveRole() {
activeRole.value = roleCode activeRole.value = roleCode
roleChangedByAdmin.value = false roleChangedByAdmin.value = false
// Refresh products if on products page // Refresh all product list pages (role-based visibility)
await refreshNuxtData('products') // 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 return true
} catch (err: any) { } catch (err: any) {
@ -112,17 +116,13 @@ export function useActiveRole() {
const hasMultipleRoles = computed(() => approvedRoles.value.length > 1) const hasMultipleRoles = computed(() => approvedRoles.value.length > 1)
/** /**
* Auto-initialize on first use (fetch role status from server) * Auto-fetch roles when user logs in
* This ensures the role is correct immediately after login * This ensures the role button shows the correct role immediately after login
* Uses callOnce to prevent redundant API calls
*/ */
const { loggedIn } = useUserSession() const { loggedIn } = useUserSession()
if (!initialized.value && loggedIn.value) { if (loggedIn.value) {
initialized.value = true callOnce('init-roles', () => fetchRoleStatus())
// 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)
})
} }
return { return {

1
app/pages/educator/index.vue

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

1
app/pages/experimenta/index.vue

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

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

@ -33,7 +33,24 @@ interface Product {
} }
// Fetch product from API // 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 // Format price in EUR
const formattedPrice = computed(() => { const formattedPrice = computed(() => {

1
app/pages/products/index.vue

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

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

@ -2,12 +2,18 @@
* GET /api/products/[id] * GET /api/products/[id]
* *
* Returns a single product by UUID. * 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 { z } from 'zod'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { products } from '../../database/schema' import { products } from '../../database/schema'
import { getVisibleProductIdsForRole } from '../../utils/roles'
import { getUserActiveRole } from '../../utils/role-session'
// UUID validation schema // UUID validation schema
const paramsSchema = z.object({ const paramsSchema = z.object({
@ -21,6 +27,29 @@ export default defineEventHandler(async (event) => {
const params = await getValidatedRouterParams(event, paramsSchema.parse) const params = await getValidatedRouterParams(event, paramsSchema.parse)
try { 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) // Fetch product by ID (must be active)
const product = await db.query.products.findFirst({ const product = await db.query.products.findFirst({
where: and(eq(products.id, params.id), eq(products.active, true)), where: and(eq(products.id, params.id), eq(products.active, true)),

Loading…
Cancel
Save