diff --git a/app/middleware/require-admin.ts b/app/middleware/require-admin.ts new file mode 100644 index 0000000..3ab3cac --- /dev/null +++ b/app/middleware/require-admin.ts @@ -0,0 +1,19 @@ +import { defineNuxtRouteMiddleware, navigateTo } from '#app' +import { useAuthStore } from '~/stores/auth' + +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + + if (!auth.initialized) { + await auth.fetchUser().catch(() => null) + } + + if (!auth.user) { + const redirect = encodeURIComponent(to.fullPath || '/admin') + return navigateTo(`/login?redirect=${redirect}`) + } + + if (!['admin', 'dev'].includes(auth.user.role)) { + return navigateTo('/') + } +}) diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue new file mode 100644 index 0000000..8e8f7d1 --- /dev/null +++ b/app/pages/admin/index.vue @@ -0,0 +1,1206 @@ + + + diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 28f29bf..88d5f52 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -581,16 +581,133 @@ - - -
- - Letzte Übertragung + + +
+
+ + + Letzte Übertragung + +
+
+ + Fehlerhaft + + + + {{ lastTransmissionFaulty ? 'Fehler bearbeiten' : 'Fehler markieren' }} + + + + Zurücksetzen + + + + Löschen + +
+
+

{{ lastTransmission }}

+
+

Fehlerbeschreibung

+

+ {{ lastTransmissionFaultNote }} +

+

+ Als fehlerhaft markiert. +

-

{{ lastTransmission }}

+ + + + + Übertragung als fehlerhaft markieren + + +

+ Hinterlasse optional eine kurze Beschreibung, damit das Dev Team die + Übertragung nachvollziehen kann. +

+ +
+ + + Abbrechen + + + Markierung entfernen + + + Speichern + + +
+
+ @@ -676,9 +793,61 @@ const { getStateDetails } = engine +const lastTransmission = ref('') +const lastTransmissionFaulty = ref(false) +const lastTransmissionFaultNote = ref('') +const showTransmissionIssueDialog = ref(false) +const transmissionIssueNote = ref('') + +function setLastTransmission(text: string) { + lastTransmission.value = text + lastTransmissionFaulty.value = false + lastTransmissionFaultNote.value = '' +} + +function clearLastTransmission() { + setLastTransmission('') +} + +function markLastTransmissionFault(note: string) { + if (!lastTransmission.value) return + lastTransmissionFaulty.value = true + lastTransmissionFaultNote.value = note +} + +function resetLastTransmissionFault() { + lastTransmissionFaulty.value = false + lastTransmissionFaultNote.value = '' +} + +function startTransmissionIssueFlow() { + if (!lastTransmission.value) return + transmissionIssueNote.value = lastTransmissionFaultNote.value + showTransmissionIssueDialog.value = true +} + +function confirmTransmissionIssue() { + if (!lastTransmission.value) { + showTransmissionIssueDialog.value = false + return + } + + markLastTransmissionFault(transmissionIssueNote.value.trim()) + showTransmissionIssueDialog.value = false +} + +function removeTransmissionIssue() { + resetLastTransmissionFault() + showTransmissionIssueDialog.value = false +} + +function cancelTransmissionIssue() { + showTransmissionIssueDialog.value = false +} + const clearLog = () => { clearCommunicationLog() - lastTransmission.value = '' + clearLastTransmission() } // UI State @@ -686,7 +855,6 @@ const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login') const loading = ref(false) const error = ref('') const pilotInput = ref('') -const lastTransmission = ref('') const radioMode = ref<'atc' | 'intercom'>('atc') const isRecording = ref(false) const micPermission = ref(false) @@ -1071,7 +1239,7 @@ const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions = if (response.success && response.audio) { if (options.updateLastTransmission !== false) { - lastTransmission.value = options.lastTransmissionLabel || `ATC: ${prepared.plain}` + setLastTransmission(options.lastTransmissionLabel || `ATC: ${prepared.plain}`) } await playAudioWithEffects(response.audio.base64) } @@ -1117,7 +1285,7 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = if (!transcript) return const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot' - lastTransmission.value = `${prefix}: ${transcript}` + setLastTransmission(`${prefix}: ${transcript}`) const quickResponse = processPilotTransmission(transcript) @@ -1142,7 +1310,7 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = } } catch (e) { console.error('LLM decision failed', e) - lastTransmission.value = `${prefix}: ${transcript} (LLM failed)` + setLastTransmission(`${prefix}: ${transcript} (LLM failed)`) } } @@ -1199,7 +1367,7 @@ const startDemoFlight = () => { const backToSetup = () => { currentScreen.value = 'login' selectedPlan.value = null - lastTransmission.value = '' + clearLastTransmission() } // Audio/PTT Functions @@ -1289,7 +1457,7 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { }) if (result.success) { - lastTransmission.value = `INTERCOM: ${result.transcription}` + setLastTransmission(`INTERCOM: ${result.transcription}`) const transcription = result.transcription.toLowerCase() if (transcription.includes('checklist') || transcription.includes('check list')) { speakWithRadioEffects('Checklist functionality available in advanced mode', { @@ -1318,7 +1486,7 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { } } catch (err) { console.error('Error processing transmission:', err) - lastTransmission.value = 'Error processing audio' + setLastTransmission('Error processing audio') } } @@ -1423,7 +1591,7 @@ const recordCurrentAtcMessage = () => { payload: { text: plain, normalized } }) - lastTransmission.value = `ATC: ${plain}` + setLastTransmission(`ATC: ${plain}`) recordedAtcStates.add(state.id) } @@ -1515,7 +1683,7 @@ const runFullSimulation = async () => { payload: { text: pilotText, normalized: pilotNormalized } }) - lastTransmission.value = `Pilot: ${pilotText}` + setLastTransmission(`Pilot: ${pilotText}`) const quickResponse = processPilotTransmission(pilotText) if (quickResponse) { @@ -1597,6 +1765,12 @@ onMounted(async () => { await requestMicAccess() }) +watch(showTransmissionIssueDialog, (open) => { + if (!open) { + transmissionIssueNote.value = '' + } +}) + // Watch for frequency changes from engine watch(() => activeFrequency.value, (newFreq) => { if (newFreq && newFreq !== frequencies.value.active) { diff --git a/app/stores/auth.ts b/app/stores/auth.ts index 6f52556..a022033 100644 --- a/app/stores/auth.ts +++ b/app/stores/auth.ts @@ -12,11 +12,13 @@ interface RegisterPayload extends Credentials { acceptPrivacy: boolean } +type UserRole = 'user' | 'admin' | 'dev' + interface AuthUser { id: string email: string name?: string - role: string + role: UserRole createdAt: string } diff --git a/server/api/admin/invitations.get.ts b/server/api/admin/invitations.get.ts new file mode 100644 index 0000000..e47512c --- /dev/null +++ b/server/api/admin/invitations.get.ts @@ -0,0 +1,113 @@ +import { createError, defineEventHandler, getQuery } from 'h3' +import type { FilterQuery } from 'mongoose' +import { requireAdmin } from '../../utils/auth' +import { InvitationCode, type InvitationCodeDocument } from '../../models/InvitationCode' + +const CHANNELS = new Set(['user', 'manual', 'bootstrap', 'admin']) + +type InvitationListItem = { + id: string + code: string + channel: string + label?: string + createdAt: string + expiresAt?: string + usedAt?: string + createdBy?: { id: string; email: string; name?: string; role: string } + usedBy?: { id: string; email: string; name?: string; role: string } +} + +function mapInvitation(doc: any): InvitationListItem { + return { + id: String(doc._id), + code: doc.code, + channel: doc.channel, + label: doc.label || undefined, + createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(), + expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined, + usedAt: doc.usedAt ? new Date(doc.usedAt).toISOString() : undefined, + createdBy: doc.createdBy + ? { + id: String(doc.createdBy._id), + email: doc.createdBy.email, + name: doc.createdBy.name || undefined, + role: doc.createdBy.role, + } + : undefined, + usedBy: doc.usedBy + ? { + id: String(doc.usedBy._id), + email: doc.usedBy.email, + name: doc.usedBy.name || undefined, + role: doc.usedBy.role, + } + : undefined, + } +} + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + const query = getQuery(event) + const search = typeof query.search === 'string' ? query.search.trim() : '' + const channel = typeof query.channel === 'string' ? query.channel.trim() : '' + const status = typeof query.status === 'string' ? query.status.trim().toLowerCase() : '' + + const page = Number.parseInt(String(query.page ?? '1'), 10) || 1 + const pageSizeRaw = Number.parseInt(String(query.pageSize ?? query.limit ?? '25'), 10) + const pageSize = Math.min(Math.max(pageSizeRaw || 25, 1), 100) + const skip = (page - 1) * pageSize + + const filter: FilterQuery = {} + const andConditions: FilterQuery[] = [] + const now = new Date() + + if (search) { + const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + andConditions.push({ $or: [{ code: regex }, { label: regex }] }) + } + + if (channel) { + if (!CHANNELS.has(channel)) { + throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' }) + } + filter.channel = channel + } + + if (status) { + if (status === 'active') { + filter.usedBy = { $exists: false } + andConditions.push({ $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gte: now } }] }) + } else if (status === 'used') { + filter.usedBy = { $exists: true } + } else if (status === 'expired') { + filter.usedBy = { $exists: false } + filter.expiresAt = { $lt: now } + } else { + throw createError({ statusCode: 400, statusMessage: 'Ungültiger Status' }) + } + } + + if (andConditions.length) { + filter.$and = andConditions + } + + const [total, items] = await Promise.all([ + InvitationCode.countDocuments(filter), + InvitationCode.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(pageSize) + .populate('createdBy', 'email name role') + .populate('usedBy', 'email name role'), + ]) + + return { + items: items.map(mapInvitation), + pagination: { + total, + page, + pageSize, + pages: Math.ceil(total / pageSize) || 1, + }, + } +}) diff --git a/server/api/admin/invitations.post.ts b/server/api/admin/invitations.post.ts new file mode 100644 index 0000000..d8418d9 --- /dev/null +++ b/server/api/admin/invitations.post.ts @@ -0,0 +1,52 @@ +import { createError, defineEventHandler, readBody } from 'h3' +import { randomBytes } from 'node:crypto' +import { requireAdmin } from '../../utils/auth' +import { InvitationCode } from '../../models/InvitationCode' + +interface CreateInvitationBody { + label?: string + expiresInDays?: number +} + +function generateCode() { + return randomBytes(4).toString('hex').toUpperCase() +} + +export default defineEventHandler(async (event) => { + const admin = await requireAdmin(event) + const body = await readBody(event).catch(() => ({} as CreateInvitationBody)) + + const label = body.label?.trim() || undefined + const expiresInDays = typeof body.expiresInDays === 'number' ? Math.max(1, Math.min(body.expiresInDays, 120)) : 30 + + const now = new Date() + const expiresAt = new Date(now.getTime() + expiresInDays * 24 * 60 * 60 * 1000) + + const code = generateCode() + + const existing = await InvitationCode.findOne({ code }) + if (existing) { + throw createError({ statusCode: 500, statusMessage: 'Generierung fehlgeschlagen, bitte erneut versuchen.' }) + } + + const invitation = await InvitationCode.create({ + code, + channel: 'admin', + label, + createdAt: now, + createdBy: admin._id, + expiresAt, + }) + + return { + success: true, + invitation: { + id: String(invitation._id), + code: invitation.code, + channel: invitation.channel, + label: invitation.label || undefined, + createdAt: invitation.createdAt.toISOString(), + expiresAt: invitation.expiresAt ? invitation.expiresAt.toISOString() : undefined, + }, + } +}) diff --git a/server/api/admin/overview.get.ts b/server/api/admin/overview.get.ts new file mode 100644 index 0000000..c6346c5 --- /dev/null +++ b/server/api/admin/overview.get.ts @@ -0,0 +1,192 @@ +import { defineEventHandler } from 'h3' +import { requireAdmin } from '../../utils/auth' +import { User } from '../../models/User' +import { InvitationCode } from '../../models/InvitationCode' +import { TransmissionLog } from '../../models/TransmissionLog' + +type RecentUser = { + id: string + email: string + name?: string + role: string + createdAt: string + lastLoginAt?: string +} + +type RecentInvitation = { + id: string + code: string + channel: string + label?: string + createdAt: string + expiresAt?: string + usedAt?: string + createdBy?: { id: string; email: string; name?: string; role: string } + usedBy?: { id: string; email: string; name?: string; role: string } +} + +type RecentTransmission = { + id: string + role: string + channel: string + direction: string + text: string + normalized?: string + createdAt: string + user?: { id: string; email: string; name?: string; role: string } + metadata?: Record +} + +function mapUser(doc: any): RecentUser { + return { + id: String(doc._id), + email: doc.email, + name: doc.name || undefined, + role: doc.role, + createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(), + lastLoginAt: doc.lastLoginAt ? new Date(doc.lastLoginAt).toISOString() : undefined, + } +} + +function mapInvitation(doc: any): RecentInvitation { + return { + id: String(doc._id), + code: doc.code, + channel: doc.channel, + label: doc.label || undefined, + createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(), + expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined, + usedAt: doc.usedAt ? new Date(doc.usedAt).toISOString() : undefined, + createdBy: doc.createdBy + ? { + id: String(doc.createdBy._id), + email: doc.createdBy.email, + name: doc.createdBy.name || undefined, + role: doc.createdBy.role, + } + : undefined, + usedBy: doc.usedBy + ? { + id: String(doc.usedBy._id), + email: doc.usedBy.email, + name: doc.usedBy.name || undefined, + role: doc.usedBy.role, + } + : undefined, + } +} + +function mapTransmission(doc: any): RecentTransmission { + return { + id: String(doc._id), + role: doc.role, + channel: doc.channel, + direction: doc.direction, + text: doc.text, + normalized: doc.normalized || undefined, + createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(), + user: doc.user + ? { + id: String(doc.user._id), + email: doc.user.email, + name: doc.user.name || undefined, + role: doc.user.role, + } + : undefined, + metadata: doc.metadata || undefined, + } +} + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const now = new Date() + const dayAgo = new Date(now.getTime() - 1000 * 60 * 60 * 24) + const weekAgo = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 7) + const weekAhead = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7) + + const [ + totalUsers, + adminCount, + devCount, + newUsersLastWeek, + totalInvitations, + activeInvitations, + expiringInvitations, + transmissionsTotal, + transmissionsLast24h, + transmissionsByChannelRaw, + transmissionsByRoleRaw, + recentUsersDocs, + recentInvitationsDocs, + recentTransmissionsDocs, + ] = await Promise.all([ + User.countDocuments(), + User.countDocuments({ role: 'admin' }), + User.countDocuments({ role: 'dev' }), + User.countDocuments({ createdAt: { $gte: weekAgo } }), + InvitationCode.countDocuments(), + InvitationCode.countDocuments({ + usedBy: { $exists: false }, + $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gte: now } }], + }), + InvitationCode.countDocuments({ + usedBy: { $exists: false }, + expiresAt: { $gte: now, $lte: weekAhead }, + }), + TransmissionLog.countDocuments(), + TransmissionLog.countDocuments({ createdAt: { $gte: dayAgo } }), + TransmissionLog.aggregate([{ $group: { _id: '$channel', count: { $sum: 1 } } }]), + TransmissionLog.aggregate([{ $group: { _id: '$role', count: { $sum: 1 } } }]), + User.find().sort({ createdAt: -1 }).limit(5), + InvitationCode.find() + .sort({ createdAt: -1 }) + .limit(5) + .populate('createdBy', 'email name role') + .populate('usedBy', 'email name role'), + TransmissionLog.find() + .sort({ createdAt: -1 }) + .limit(5) + .populate('user', 'email name role'), + ]) + + const transmissionsByChannel = transmissionsByChannelRaw.reduce( + (acc: Record, item: any) => { + acc[item._id || 'unknown'] = item.count + return acc + }, + {} as Record, + ) + + const transmissionsByRole = transmissionsByRoleRaw.reduce( + (acc: Record, item: any) => { + acc[item._id || 'unknown'] = item.count + return acc + }, + {} as Record, + ) + + return { + generatedAt: now.toISOString(), + users: { + total: totalUsers, + admins: adminCount, + devs: devCount, + newLast7Days: newUsersLastWeek, + recent: recentUsersDocs.map(mapUser), + }, + invitations: { + total: totalInvitations, + active: activeInvitations, + expiringSoon: expiringInvitations, + recent: recentInvitationsDocs.map(mapInvitation), + }, + transmissions: { + total: transmissionsTotal, + last24h: transmissionsLast24h, + byChannel: transmissionsByChannel, + byRole: transmissionsByRole, + recent: recentTransmissionsDocs.map(mapTransmission), + }, + } +}) diff --git a/server/api/admin/users.get.ts b/server/api/admin/users.get.ts new file mode 100644 index 0000000..d162c34 --- /dev/null +++ b/server/api/admin/users.get.ts @@ -0,0 +1,81 @@ +import { createError, defineEventHandler, getQuery } from 'h3' +import type { FilterQuery } from 'mongoose' +import { requireAdmin } from '../../utils/auth' +import { User, type UserDocument } from '../../models/User' + +type UserListItem = { + id: string + email: string + name?: string + role: string + createdAt: string + lastLoginAt?: string + invitationCodesIssued: number +} + +function mapUser(doc: any): UserListItem { + return { + id: String(doc._id), + email: doc.email, + name: doc.name || undefined, + role: doc.role, + createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(), + lastLoginAt: doc.lastLoginAt ? new Date(doc.lastLoginAt).toISOString() : undefined, + invitationCodesIssued: doc.invitationCodesIssued || 0, + } +} + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + const query = getQuery(event) + + const search = typeof query.search === 'string' ? query.search.trim() : '' + const role = typeof query.role === 'string' ? query.role.trim() : '' + const page = Number.parseInt(String(query.page ?? '1'), 10) || 1 + const pageSizeRaw = Number.parseInt(String(query.pageSize ?? query.limit ?? '20'), 10) + const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100) + const skip = (page - 1) * pageSize + + const filter: FilterQuery = {} + + if (search) { + const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + filter.$or = [{ email: regex }, { name: regex }] + } + + if (role) { + if (!['user', 'admin', 'dev'].includes(role)) { + throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' }) + } + filter.role = role + } + + const [total, items] = await Promise.all([ + User.countDocuments(filter), + User.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(pageSize), + ]) + + const [userCount, adminCount, devCount] = await Promise.all([ + User.countDocuments({ role: 'user' }), + User.countDocuments({ role: 'admin' }), + User.countDocuments({ role: 'dev' }), + ]) + + return { + items: items.map(mapUser), + pagination: { + total, + page, + pageSize, + pages: Math.ceil(total / pageSize) || 1, + }, + roles: { + user: userCount, + admin: adminCount, + dev: devCount, + }, + } +}) diff --git a/server/api/admin/users/[id]/role.patch.ts b/server/api/admin/users/[id]/role.patch.ts new file mode 100644 index 0000000..aad0f57 --- /dev/null +++ b/server/api/admin/users/[id]/role.patch.ts @@ -0,0 +1,52 @@ +import { createError, defineEventHandler, readBody } from 'h3' +import { requireAdmin } from '../../../../utils/auth' +import { User } from '../../../../models/User' + +type UpdateRoleBody = { + role?: string +} + +export default defineEventHandler(async (event) => { + const admin = await requireAdmin(event) + const params = event.context.params as { id?: string } + const userId = params?.id + + if (!userId) { + throw createError({ statusCode: 400, statusMessage: 'User-ID fehlt' }) + } + + const body = await readBody(event).catch(() => ({})) + const role = body.role?.trim() + + if (!role || !['user', 'admin', 'dev'].includes(role)) { + throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' }) + } + + const target = await User.findById(userId) + if (!target) { + throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' }) + } + + const previousRole = target.role + target.role = role as any + + if (role !== previousRole) { + target.tokenVersion += 1 + } + + await target.save() + + return { + success: true, + user: { + id: String(target._id), + email: target.email, + name: target.name || undefined, + role: target.role, + createdAt: target.createdAt ? target.createdAt.toISOString() : new Date().toISOString(), + lastLoginAt: target.lastLoginAt ? target.lastLoginAt.toISOString() : undefined, + invitationCodesIssued: target.invitationCodesIssued || 0, + updatedBy: String(admin._id), + }, + } +}) diff --git a/server/models/InvitationCode.ts b/server/models/InvitationCode.ts index 0d1f401..a0e95eb 100644 --- a/server/models/InvitationCode.ts +++ b/server/models/InvitationCode.ts @@ -2,6 +2,8 @@ import mongoose from 'mongoose' const { Schema } = mongoose +export type InvitationChannel = 'user' | 'bootstrap' | 'manual' | 'admin' + export interface InvitationCodeDocument extends mongoose.Document { code: string createdBy?: mongoose.Types.ObjectId @@ -9,7 +11,7 @@ export interface InvitationCodeDocument extends mongoose.Document { expiresAt?: Date usedBy?: mongoose.Types.ObjectId usedAt?: Date - channel: 'user' | 'bootstrap' | 'manual' + channel: InvitationChannel label?: string } @@ -20,7 +22,7 @@ const invitationSchema = new mongoose.Schema({ expiresAt: { type: Date }, usedBy: { type: Schema.Types.ObjectId, ref: 'User' }, usedAt: { type: Date }, - channel: { type: String, enum: ['user', 'bootstrap', 'manual'], default: 'user' }, + channel: { type: String, enum: ['user', 'bootstrap', 'manual', 'admin'], default: 'user' }, label: { type: String, trim: true }, }) diff --git a/server/models/User.ts b/server/models/User.ts index e551dc5..65c71c0 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -1,10 +1,12 @@ import mongoose from 'mongoose' +export type UserRole = 'user' | 'admin' | 'dev' + export interface UserDocument extends mongoose.Document { email: string passwordHash: string name?: string - role: 'user' | 'admin' + role: UserRole createdAt: Date lastLoginAt?: Date tokenVersion: number @@ -17,7 +19,7 @@ const userSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true, lowercase: true, trim: true }, passwordHash: { type: String, required: true }, name: { type: String, trim: true }, - role: { type: String, enum: ['user', 'admin'], default: 'user' }, + role: { type: String, enum: ['user', 'admin', 'dev'], default: 'user' }, createdAt: { type: Date, default: () => new Date() }, lastLoginAt: { type: Date }, tokenVersion: { type: Number, default: 0 }, diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 6e3783b..600bd08 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -170,6 +170,18 @@ export async function getUserFromEvent(event: H3Event) { return user } +export function hasAdminRole(user: UserDocument | null | undefined) { + return user ? user.role === 'admin' || user.role === 'dev' : false +} + +export async function requireAdmin(event: H3Event) { + const user = await requireUserSession(event) + if (!hasAdminRole(user)) { + throw createError({ statusCode: 403, statusMessage: 'Administratorrechte erforderlich' }) + } + return user +} + export async function issueAuthTokens(event: H3Event, user: UserDocument) { const accessToken = createAccessToken(user) const refreshToken = createRefreshToken(user)