Browse Source
- 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.main
16 changed files with 1576 additions and 91 deletions
@ -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, |
||||
|
} |
||||
|
} |
||||
@ -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', |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
@ -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, |
||||
|
} |
||||
|
}) |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE "users" ADD COLUMN "last_active_role_code" "role_code"; |
||||
File diff suppressed because it is too large
@ -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)) |
||||
|
} |
||||
Loading…
Reference in new issue