Implement Role Management Features and UI Enhancements

- Introduced a new composable `useActiveRole` for managing user roles, including fetching role status and switching roles with server validation.
- Updated `RoleSwitcher.vue` to utilize the new composable, enhancing role selection with improved error handling and UI feedback.
- Added new API endpoints for role management, including fetching user role status and switching active roles.
- Enhanced product visibility logic to filter based on the user's active role, ensuring a tailored experience.
- Updated database schema to support last active role tracking for users, improving session management.
- Refined UI components across the application to reflect role-based changes and improve user experience.
This commit is contained in:
Bastian Masanek
2025-11-05 01:04:26 +01:00
parent 0e450684c6
commit f9125e744b
16 changed files with 1573 additions and 88 deletions

View File

@@ -74,7 +74,6 @@ async function handleLogout() {
<!-- Avatar with status ring (logged in indicator) -->
<Avatar
class="h-10 w-10 md:h-12 md:w-12 border-3 border-experimenta-purple shadow-lg bg-experimenta-accent ring-2 ring-experimenta-accent ring-offset-2 ring-offset-white dark:ring-offset-zinc-950">
<AvatarImage :src="undefined" :alt="extendedUser?.firstName || ''" />
<AvatarFallback
class="bg-experimenta-accent text-white font-bold text-sm md:text-base flex items-center justify-center w-full h-full">
{{ userInitials }}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { User, GraduationCap, Building2, Check } from 'lucide-vue-next'
import { User, GraduationCap, Building2, Check, AlertCircle, ChevronDown } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,110 +12,103 @@ import {
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
// User role types matching our database schema
type UserRole = 'private' | 'educator' | 'company'
// Role management composable
const { activeRole, roles, roleChangedByAdmin, switchRole, fetchRoleStatus, loading } =
useActiveRole()
interface RoleOption {
value: UserRole
label: string
description: string
icon: any
color: string
enabled: boolean
badge?: string
// Icon mapping for each role
const roleIcons: Record<string, any> = {
private: User,
educator: GraduationCap,
company: Building2,
}
const roles: RoleOption[] = [
{
value: 'private',
label: 'Privatperson',
description: 'Private Nutzung',
icon: User,
color: 'text-purple-600',
enabled: true,
},
{
value: 'educator',
label: 'Pädagoge',
description: 'Lehrkräfte und Schulen',
icon: GraduationCap,
color: 'text-orange-500',
enabled: true,
// badge: 'Demnächst',
},
{
value: 'company',
label: 'Firma',
description: 'Geschäftskunden',
icon: Building2,
color: 'text-blue-600',
enabled: true,
// badge: 'Demnächst',
},
]
// Color mapping for each role
const roleColors: Record<string, string> = {
private: 'text-purple-600',
educator: 'text-orange-500',
company: 'text-blue-600',
}
// Current role - will come from user session later
const currentRole = ref<UserRole>('private')
// Dropdown open/close state
const dropdownOpen = ref(false)
const currentRoleData = computed(() => {
return roles.find((r) => r.value === currentRole.value) || roles[0]
// Fetch role status when dropdown opens
watch(dropdownOpen, async (isOpen) => {
if (isOpen && !loading.value) {
await fetchRoleStatus()
}
})
function switchRole(role: UserRole) {
if (roles.find((r) => r.value === role)?.enabled) {
currentRole.value = role
// TODO: Update user session/store with new role
// Current role data (for button display)
const currentRoleData = computed(() => roles.value.find((r) => r.code === activeRole.value))
// Handle role switch with error handling
async function handleRoleSwitch(roleCode: string, hasRole: boolean) {
if (!hasRole || loading.value) return
try {
await switchRole(roleCode)
dropdownOpen.value = false // Close dropdown after successful switch
} catch (error) {
console.error('Role switch failed:', error)
// Error is already set in composable, will be displayed if needed
}
}
</script>
<template>
<DropdownMenu>
<dropdownMenuTrigger as-child>
<DropdownMenu v-model:open="dropdownOpen">
<DropdownMenuTrigger as-child>
<Button variant="outline"
class="gap-2 border-2 border-white/30 bg-white/5 text-white hover:bg-white/10 hover:border-white/50 transition-all duration-200 group"
title="Rolle wechseln">
<!-- Context label + role -->
<span class="text-xs text-white/70 font-normal hidden md:inline">Du kaufst als:</span>
<component :is="currentRoleData.icon" class="h-4 w-4 text-white" />
<span class="font-medium text-white">{{ currentRoleData.label }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="ml-1 opacity-70 text-white group-hover:opacity-100 transition-opacity">
<polyline points="6 9 12 15 18 9" />
</svg>
<component :is="roleIcons[activeRole] || User" class="h-4 w-4 text-white" />
<span class="font-medium text-white">{{
currentRoleData?.displayName || 'Privatperson'
}}</span>
<ChevronDown class="h-4 w-4 text-white/70 group-hover:text-white transition-colors" />
</Button>
</dropdownMenuTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-[312px]">
<!-- Admin Notice (if role was changed by admin) -->
<Alert v-if="roleChangedByAdmin" variant="warning" class="m-2 mb-3">
<AlertCircle class="h-4 w-4" />
<AlertDescription class="text-sm">
Deine Rolle wurde von einem Administrator geändert.
</AlertDescription>
</Alert>
<DropdownMenuLabel class="text-sm font-normal text-muted-foreground py-3">
Ich kaufe als...
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem v-for="role in roles" :key="role.value" :disabled="!role.enabled" :class="[
<!-- All roles (approved + not-approved) -->
<DropdownMenuItem v-for="role in roles" :key="role.code" :disabled="!role.hasRole || loading" :class="[
'gap-3 py-3 cursor-pointer group',
!role.enabled && 'opacity-60 cursor-not-allowed',
currentRole === role.value && 'bg-purple-50 dark:bg-purple-950',
]" @click="switchRole(role.value)">
<component :is="role.icon" :class="['h-5 w-5', role.color]" />
!role.hasRole && 'opacity-60 cursor-not-allowed',
activeRole === role.code && 'bg-purple-50 dark:bg-purple-950',
]" @click="handleRoleSwitch(role.code, role.hasRole)">
<component :is="roleIcons[role.code]" :class="['h-5 w-5', roleColors[role.code]]" />
<div class="flex-1 flex flex-col gap-1">
<span class="text-sm font-semibold">{{ role.label }}</span>
<span class="text-sm font-semibold">{{ role.displayName }}</span>
<span
class="text-sm text-muted-foreground group-hover:text-foreground group-data-[highlighted]:text-accent-foreground transition-colors">{{
role.description }}</span>
class="text-sm text-muted-foreground group-hover:text-foreground group-data-[highlighted]:text-accent-foreground transition-colors">
{{ role.description }}
</span>
</div>
<!-- High-contrast checkmark for colorblind accessibility -->
<div v-if="currentRole === role.value" 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>
<!-- Checkmark if active -->
<Check v-if="activeRole === role.code && role.hasRole" class="h-5 w-5 text-success flex-shrink-0" />
<Badge v-if="role.badge" variant="secondary" class="text-xs px-2 py-0.5 flex-shrink-0">
{{ role.badge }}
<!-- Badge if not approved -->
<Badge v-if="!role.hasRole" variant="secondary" class="text-xs px-2 py-0.5 flex-shrink-0">
{{ role.requiresApproval ? 'Demnächst' : 'Gesperrt' }}
</Badge>
</DropdownMenuItem>

View File

@@ -0,0 +1,142 @@
/**
* useActiveRole Composable
*
* Client-side state management for user's active role (RoleSwitcher)
*
* Features:
* - Reactive active role state
* - List of all roles with approval status
* - Switch role function with server validation
* - Admin change notification flag
*
* Usage:
* ```vue
* const { activeRole, roles, roleChangedByAdmin, switchRole, fetchRoleStatus } = useActiveRole()
*
* // Fetch current status (validates server-side)
* await fetchRoleStatus()
*
* // Switch role
* await switchRole('educator')
* ```
*/
export interface RoleWithStatus {
code: string
displayName: string
description: string
hasRole: boolean
requiresApproval: boolean
}
export function useActiveRole() {
// Use useState for shared state across the app
const activeRole = useState<string>('activeRole', () => 'private')
const roles = useState<RoleWithStatus[]>('roles', () => [])
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
* - Validates active role (TTL-based)
* - Fetches all roles with approval status
* - Detects if admin changed user's roles
*/
async function fetchRoleStatus() {
loading.value = true
error.value = null
try {
const data = await $fetch('/api/user/role-status')
activeRole.value = data.activeRoleCode
roles.value = data.roles
roleChangedByAdmin.value = data.roleChangedByAdmin
return data
} catch (err: any) {
console.error('Failed to fetch role status:', err)
error.value = 'Fehler beim Laden der Rollen'
throw err
} finally {
loading.value = false
}
}
/**
* Switch to a different role
* - Validates server-side that user has the role
* - Updates session + database
* - Refreshes products list if on products page
*
* @param roleCode - Role to switch to ('private', 'educator', 'company')
* @throws Error if user doesn't have the role or if switch fails
*/
async function switchRole(roleCode: string) {
loading.value = true
error.value = null
try {
await $fetch('/api/user/active-role', {
method: 'PATCH',
body: { roleCode },
})
// Update local state
activeRole.value = roleCode
roleChangedByAdmin.value = false
// Refresh products if on products page
await refreshNuxtData('products')
return true
} catch (err: any) {
console.error('Failed to switch role:', err)
error.value = err.data?.message || 'Fehler beim Wechseln der Rolle'
throw err
} finally {
loading.value = false
}
}
/**
* Get roles that user actually has (approved only)
*/
const approvedRoles = computed(() => roles.value.filter((r) => r.hasRole))
/**
* Check if user has multiple roles
*/
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
*/
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)
})
}
return {
// State (useState already returns writable refs)
activeRole,
roles,
approvedRoles,
hasMultipleRoles,
roleChangedByAdmin,
loading,
error,
// Actions
fetchRoleStatus,
switchRole,
}
}

View File

@@ -100,9 +100,9 @@ async function handleCheckout(checkoutData: CheckoutData) {
<div class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-4xl font-bold text-white mb-2">Zur Kasse</h1>
<p class="text-white/70">
<div class="mb-12 text-center">
<h1 class="text-4xl font-bold text-white mb-4 md:text-5xl">Zur Kasse</h1>
<p class="mx-auto max-w-2xl text-lg text-white/80">
Bitte gib deine Rechnungsadresse ein, um die Bestellung abzuschließen.
</p>
</div>