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

@@ -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,
}

View File

@@ -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 []
}

View 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',
}
}
})

View 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,
}
})

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "last_active_role_code" "role_code";

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1762176703220,
"tag": "0002_heavy_namora",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1762266703780,
"tag": "0003_charming_zzzax",
"breakpoints": true
}
]
}

View File

@@ -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(),
})

View File

@@ -88,7 +88,7 @@ const mockProducts: Array<{
stockQuantity: 99999,
category: 'educator-annual-pass',
active: true,
roles: ['private', 'educator'],
roles: ['educator'],
},
]

View 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))
}

View File

@@ -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