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:
@@ -80,7 +80,12 @@
|
||||
"Bash(pnpm remove:*)",
|
||||
"Bash(pnpm db:generate:*)",
|
||||
"Bash(pnpm tsx:*)",
|
||||
"Bash(node test-roleswitcher-keyboard.mjs:*)"
|
||||
"Bash(node test-roleswitcher-keyboard.mjs:*)",
|
||||
"Bash(tmux capture-pane:*)",
|
||||
"Bash(tmux list-sessions:*)",
|
||||
"Bash(tmux send-keys:*)",
|
||||
"Bash(tmux new-session:*)",
|
||||
"Bash(tmux kill-session:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
142
app/composables/useActiveRole.ts
Normal file
142
app/composables/useActiveRole.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -86,7 +86,16 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create encrypted session
|
||||
// 6. Determine initial active role
|
||||
const approvedRoles = await getUserApprovedRoleCodes(user.id)
|
||||
|
||||
// Use last active role if user had one and still has that role
|
||||
const initialRole =
|
||||
user.lastActiveRoleCode && approvedRoles.includes(user.lastActiveRoleCode)
|
||||
? user.lastActiveRoleCode
|
||||
: approvedRoles[0] || 'private'
|
||||
|
||||
// 7. Create encrypted session
|
||||
await setUserSession(event, {
|
||||
user: {
|
||||
id: user.id,
|
||||
@@ -103,9 +112,14 @@ export default defineEventHandler(async (event) => {
|
||||
},
|
||||
accessToken: tokens.access_token, // Store for logout
|
||||
loggedInAt: new Date().toISOString(),
|
||||
// Role state management
|
||||
activeRoleCode: initialRole,
|
||||
userApprovedRoles: approvedRoles,
|
||||
rolesLastValidated: new Date().toISOString(),
|
||||
roleChangedByAdmin: false,
|
||||
})
|
||||
|
||||
// 7. Return success
|
||||
// 8. Return success
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* GET /api/products
|
||||
*
|
||||
* Returns a list of products visible to the current user based on their roles.
|
||||
* Returns a list of products visible to the current user based on their active role.
|
||||
*
|
||||
* Role-based Visibility (MVP):
|
||||
* - Unauthenticated users: See NO products (empty array)
|
||||
* - Authenticated users: See products assigned to their approved roles
|
||||
* - Authenticated users: See products assigned to their ACTIVE role only
|
||||
* - Products WITHOUT role assignments: NOT visible (opt-in visibility)
|
||||
*
|
||||
* Query Parameters:
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import { eq, and, inArray } from 'drizzle-orm'
|
||||
import { products } from '../../database/schema'
|
||||
import { getVisibleProductIdsForUser } from '../../utils/roles'
|
||||
import { getVisibleProductIdsForRole } from '../../utils/roles'
|
||||
import { getUserActiveRole } from '../../utils/role-session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = useDatabase()
|
||||
@@ -32,10 +33,13 @@ export default defineEventHandler(async (event) => {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get product IDs visible to this user (based on approved roles)
|
||||
const visibleProductIds = await getVisibleProductIdsForUser(user.id)
|
||||
// Get user's active role (validates with TTL, auto-fallback if revoked)
|
||||
const activeRole = await getUserActiveRole(event)
|
||||
|
||||
// If user has no approved roles or no products are assigned to their roles
|
||||
// Get product IDs visible for the active role only
|
||||
const visibleProductIds = await getVisibleProductIdsForRole(user.id, activeRole)
|
||||
|
||||
// If user has no access to products in their active role
|
||||
if (visibleProductIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
64
server/api/user/active-role.patch.ts
Normal file
64
server/api/user/active-role.patch.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* PATCH /api/user/active-role
|
||||
*
|
||||
* Switch user's active role (used by RoleSwitcher component)
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* "roleCode": "educator"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "activeRoleCode": "educator"
|
||||
* }
|
||||
*
|
||||
* Validates that user has the requested role before switching
|
||||
* Updates both session (immediate) and database (preference)
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { setUserActiveRole } from '../../utils/role-session'
|
||||
|
||||
const switchRoleSchema = z.object({
|
||||
roleCode: z.enum(['private', 'educator', 'company'], {
|
||||
errorMap: () => ({ message: 'Ungültige Rolle' }),
|
||||
}),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Require authentication
|
||||
await requireUserSession(event)
|
||||
|
||||
// Validate request body
|
||||
const body = await readBody(event)
|
||||
const { roleCode } = switchRoleSchema.parse(body)
|
||||
|
||||
try {
|
||||
// Set active role (validates + updates session + saves to DB)
|
||||
await setUserActiveRole(event, roleCode)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
activeRoleCode: roleCode,
|
||||
}
|
||||
} catch (error: any) {
|
||||
// setUserActiveRole throws 403 if user doesn't have role
|
||||
if (error.statusCode === 403) {
|
||||
setResponseStatus(event, 403)
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Du hast diese Rolle nicht',
|
||||
}
|
||||
}
|
||||
|
||||
// Other errors
|
||||
console.error('Role switch error:', error)
|
||||
setResponseStatus(event, 500)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fehler beim Wechseln der Rolle',
|
||||
}
|
||||
}
|
||||
})
|
||||
69
server/api/user/role-status.get.ts
Normal file
69
server/api/user/role-status.get.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* GET /api/user/role-status
|
||||
*
|
||||
* Get user's active role and all available roles (for RoleSwitcher dropdown)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "activeRoleCode": "private",
|
||||
* "roles": [
|
||||
* {
|
||||
* "code": "private",
|
||||
* "displayName": "Privatperson",
|
||||
* "description": "Private Nutzung",
|
||||
* "hasRole": true,
|
||||
* "requiresApproval": false
|
||||
* },
|
||||
* {
|
||||
* "code": "educator",
|
||||
* "displayName": "Pädagoge",
|
||||
* "description": "Lehrkräfte und Schulen",
|
||||
* "hasRole": false,
|
||||
* "requiresApproval": true
|
||||
* },
|
||||
* ...
|
||||
* ],
|
||||
* "roleChangedByAdmin": false
|
||||
* }
|
||||
*
|
||||
* - Validates active role with TTL (re-checks DB every 5min)
|
||||
* - Returns ALL roles (approved + not-approved) for dropdown
|
||||
* - Includes "hasRole" flag to show which roles user actually has
|
||||
*/
|
||||
|
||||
import { asc, eq } from 'drizzle-orm'
|
||||
import { roles } from '../../database/schema'
|
||||
import { getUserActiveRole } from '../../utils/role-session'
|
||||
import { getUserApprovedRoleCodes } from '../../utils/roles'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireUserSession(event)
|
||||
|
||||
// Get active role (validates with TTL, auto-fallback if revoked)
|
||||
const activeRole = await getUserActiveRole(event)
|
||||
|
||||
// Get user's approved role codes
|
||||
const approvedRoleCodes = await getUserApprovedRoleCodes(session.user.id)
|
||||
|
||||
// Get ALL roles from database (for dropdown: show all, disabled if not approved)
|
||||
const db = useDatabase()
|
||||
const allRoles = await db.query.roles.findMany({
|
||||
where: eq(roles.active, true),
|
||||
orderBy: asc(roles.sortOrder),
|
||||
})
|
||||
|
||||
// Map roles with "hasRole" status
|
||||
const rolesWithStatus = allRoles.map((role) => ({
|
||||
code: role.code,
|
||||
displayName: role.displayName,
|
||||
description: role.description,
|
||||
hasRole: approvedRoleCodes.includes(role.code),
|
||||
requiresApproval: role.requiresApproval,
|
||||
}))
|
||||
|
||||
return {
|
||||
activeRoleCode: activeRole,
|
||||
roles: rolesWithStatus,
|
||||
roleChangedByAdmin: session.roleChangedByAdmin || false,
|
||||
}
|
||||
})
|
||||
1
server/database/migrations/0003_charming_zzzax.sql
Normal file
1
server/database/migrations/0003_charming_zzzax.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "last_active_role_code" "role_code";
|
||||
1030
server/database/migrations/meta/0003_snapshot.json
Normal file
1030
server/database/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1762176703220,
|
||||
"tag": "0002_heavy_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1762266703780,
|
||||
"tag": "0003_charming_zzzax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -127,6 +127,9 @@ export const users = pgTable('users', {
|
||||
city: text('city'),
|
||||
countryCode: text('country_code'), // ISO 3166-1 alpha-2 (e.g., 'DE', 'AT')
|
||||
|
||||
// Role preference (last selected role, used as default after login)
|
||||
lastActiveRoleCode: roleCodeEnum('last_active_role_code'),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ const mockProducts: Array<{
|
||||
stockQuantity: 99999,
|
||||
category: 'educator-annual-pass',
|
||||
active: true,
|
||||
roles: ['private', 'educator'],
|
||||
roles: ['educator'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
111
server/utils/role-session.ts
Normal file
111
server/utils/role-session.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { users } from '../database/schema'
|
||||
import { getUserApprovedRoleCodes } from './roles'
|
||||
|
||||
/**
|
||||
* Role validation cache TTL (Time To Live)
|
||||
* Roles are re-validated against DB after this period
|
||||
*/
|
||||
const ROLE_VALIDATION_TTL = 5 * 60 * 1000 // 5 minutes in milliseconds
|
||||
|
||||
/**
|
||||
* Get user's active role with automatic validation
|
||||
*
|
||||
* Features:
|
||||
* - Returns cached role from session if TTL hasn't expired
|
||||
* - Re-validates against DB after TTL expires (every 5 minutes)
|
||||
* - Auto-fallback if active role was revoked by admin
|
||||
* - Updates session automatically with validated data
|
||||
*
|
||||
* @param event - H3 Event from API handler
|
||||
* @returns Active role code (e.g., 'private', 'educator', 'company')
|
||||
* @throws 401 if user is not authenticated
|
||||
*/
|
||||
export async function getUserActiveRole(event: H3Event): Promise<string> {
|
||||
const session = await getUserSession(event)
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Not authenticated',
|
||||
})
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const lastValidated = session.rolesLastValidated
|
||||
? new Date(session.rolesLastValidated).getTime()
|
||||
: 0
|
||||
const needsRevalidation = now - lastValidated > ROLE_VALIDATION_TTL
|
||||
|
||||
if (needsRevalidation) {
|
||||
// TTL expired → Re-validate against database
|
||||
const currentRoles = await getUserApprovedRoleCodes(session.user.id)
|
||||
const activeRole = session.activeRoleCode || 'private'
|
||||
|
||||
// Check: Was active role revoked by admin?
|
||||
if (!currentRoles.includes(activeRole)) {
|
||||
// Auto-fallback to first available role
|
||||
const newActiveRole = currentRoles[0] || 'private'
|
||||
|
||||
await replaceUserSession(event, {
|
||||
activeRoleCode: newActiveRole,
|
||||
userApprovedRoles: currentRoles,
|
||||
rolesLastValidated: new Date().toISOString(),
|
||||
roleChangedByAdmin: true, // Flag for client notification
|
||||
})
|
||||
|
||||
return newActiveRole
|
||||
}
|
||||
|
||||
// Role still valid, just update cache + timestamp
|
||||
await replaceUserSession(event, {
|
||||
userApprovedRoles: currentRoles,
|
||||
rolesLastValidated: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return session.activeRoleCode || 'private'
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's active role with validation
|
||||
*
|
||||
* Validates that user has the requested role before switching.
|
||||
* Updates both session (immediate) and database (preference for next login).
|
||||
*
|
||||
* @param event - H3 Event from API handler
|
||||
* @param roleCode - Role to switch to (must be in user's approved roles)
|
||||
* @throws 401 if not authenticated
|
||||
* @throws 403 if user doesn't have the requested role
|
||||
*/
|
||||
export async function setUserActiveRole(
|
||||
event: H3Event,
|
||||
roleCode: string
|
||||
): Promise<void> {
|
||||
const session = await requireUserSession(event)
|
||||
|
||||
// Validate that user has this role
|
||||
const approvedRoles = await getUserApprovedRoleCodes(session.user.id)
|
||||
|
||||
if (!approvedRoles.includes(roleCode)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: 'Du hast diese Rolle nicht',
|
||||
})
|
||||
}
|
||||
|
||||
// Update session with new active role
|
||||
await replaceUserSession(event, {
|
||||
activeRoleCode: roleCode,
|
||||
roleChangedByAdmin: false, // User switched manually
|
||||
rolesLastValidated: new Date().toISOString(), // Reset TTL
|
||||
})
|
||||
|
||||
// Update database: Save as user preference for next login
|
||||
const db = useDatabase()
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastActiveRoleCode: roleCode })
|
||||
.where(eq(users.id, session.user.id))
|
||||
}
|
||||
@@ -120,6 +120,48 @@ export async function getVisibleProductIdsForUser(userId: string): Promise<strin
|
||||
return [...new Set(visibleProducts.map((pv) => pv.productId))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products visible for a SPECIFIC role (used with RoleSwitcher)
|
||||
* Unlike getVisibleProductIdsForUser(), this only returns products for ONE role
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const activeRole = await getUserActiveRole(event)
|
||||
* const visibleProductIds = await getVisibleProductIdsForRole(userId, activeRole)
|
||||
* ```
|
||||
*
|
||||
* @param userId - The user's UUID
|
||||
* @param roleCode - Specific role to filter by ('private', 'educator', 'company')
|
||||
* @returns Array of product UUIDs visible for this role
|
||||
*/
|
||||
export async function getVisibleProductIdsForRole(
|
||||
userId: string,
|
||||
roleCode: string
|
||||
): Promise<string[]> {
|
||||
const db = useDatabase()
|
||||
|
||||
// Verify user has this role (approved)
|
||||
const hasRole = await db.query.userRoles.findFirst({
|
||||
where: and(
|
||||
eq(userRoles.userId, userId),
|
||||
eq(userRoles.roleCode, roleCode),
|
||||
eq(userRoles.status, 'approved')
|
||||
),
|
||||
})
|
||||
|
||||
// User doesn't have this role → return empty array
|
||||
if (!hasRole) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get all products assigned to this specific role
|
||||
const visibleProducts = await db.query.productRoleVisibility.findMany({
|
||||
where: eq(productRoleVisibility.roleCode, roleCode),
|
||||
})
|
||||
|
||||
return visibleProducts.map((pv) => pv.productId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a role to a user (MVP: manual assignment, always approved)
|
||||
* Phase 2/3: This will be replaced by role request workflow
|
||||
|
||||
Reference in New Issue
Block a user