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