Finalize admin tools and transmission fault flow

This commit is contained in:
Remi
2025-09-18 13:56:48 +02:00
committed by itsrubberduck
parent 97e80292a3
commit 3efd64a5b2
12 changed files with 1928 additions and 21 deletions

View File

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

1206
app/pages/admin/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -581,16 +581,133 @@
</v-card>
<!-- Last Transmission -->
<v-card v-if="lastTransmission" class="bg-cyan-500/10 border border-cyan-400/20">
<v-card-text class="space-y-2">
<div class="flex items-center gap-2">
<v-icon icon="mdi-microphone-outline" size="16" class="text-cyan-300" />
<span class="text-xs uppercase tracking-[0.3em] text-cyan-300">Letzte Übertragung</span>
<v-card
v-if="lastTransmission"
:class="[
'border backdrop-blur',
lastTransmissionFaulty
? 'bg-red-500/10 border-red-400/30'
: 'bg-cyan-500/10 border-cyan-400/20'
]"
>
<v-card-text class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<v-icon
icon="mdi-microphone-outline"
size="16"
:class="lastTransmissionFaulty ? 'text-red-300' : 'text-cyan-300'"
/>
<span
class="text-xs uppercase tracking-[0.3em]"
:class="lastTransmissionFaulty ? 'text-red-300' : 'text-cyan-300'"
>
Letzte Übertragung
</span>
</div>
<div class="flex flex-wrap items-center gap-1">
<v-chip
v-if="lastTransmissionFaulty"
size="x-small"
color="red"
variant="flat"
>
Fehlerhaft
</v-chip>
<v-btn
variant="text"
size="small"
:color="lastTransmissionFaulty ? 'red' : 'amber'"
class="text-[0.65rem] uppercase tracking-[0.3em]"
@click="startTransmissionIssueFlow"
>
<template #prepend>
<v-icon size="16">mdi-alert-circle-outline</v-icon>
</template>
{{ lastTransmissionFaulty ? 'Fehler bearbeiten' : 'Fehler markieren' }}
</v-btn>
<v-btn
v-if="lastTransmissionFaulty"
variant="text"
size="small"
color="cyan"
class="text-[0.65rem] uppercase tracking-[0.3em]"
@click="resetLastTransmissionFault"
>
<template #prepend>
<v-icon size="16">mdi-restore</v-icon>
</template>
Zurücksetzen
</v-btn>
<v-btn
variant="text"
size="small"
color="cyan"
class="text-[0.65rem] uppercase tracking-[0.3em]"
@click="clearLastTransmission"
>
<template #prepend>
<v-icon size="16">mdi-close</v-icon>
</template>
Löschen
</v-btn>
</div>
</div>
<p class="text-sm text-white font-mono whitespace-pre-line">{{ lastTransmission }}</p>
<div
v-if="lastTransmissionFaulty"
class="space-y-2 rounded-xl border border-red-400/30 bg-red-500/10 px-3 py-2"
>
<p class="text-xs uppercase tracking-[0.25em] text-red-200/80">Fehlerbeschreibung</p>
<p v-if="lastTransmissionFaultNote" class="text-sm text-red-100">
{{ lastTransmissionFaultNote }}
</p>
<p v-else class="text-xs text-red-200/70">
Als fehlerhaft markiert.
</p>
</div>
<p class="text-sm text-white font-mono">{{ lastTransmission }}</p>
</v-card-text>
</v-card>
<v-dialog v-model="showTransmissionIssueDialog" max-width="420">
<v-card class="bg-[#0b101d] border border-white/10 text-white">
<v-card-title class="flex items-center gap-2 text-base font-semibold">
<v-icon icon="mdi-alert-circle-outline" color="#f87171" size="20" />
Übertragung als fehlerhaft markieren
</v-card-title>
<v-card-text class="space-y-4 text-sm text-white/70">
<p>
Hinterlasse optional eine kurze Beschreibung, damit das Dev Team die
Übertragung nachvollziehen kann.
</p>
<v-textarea
v-model="transmissionIssueNote"
label="Fehlerbeschreibung (optional)"
variant="outlined"
color="red"
rows="3"
auto-grow
/>
</v-card-text>
<v-card-actions class="justify-end gap-2">
<v-btn variant="text" color="grey" @click="cancelTransmissionIssue">
Abbrechen
</v-btn>
<v-btn
v-if="lastTransmissionFaulty"
variant="text"
color="cyan"
@click="removeTransmissionIssue"
>
Markierung entfernen
</v-btn>
<v-btn color="red" variant="flat" @click="confirmTransmissionIssue">
Speichern
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Settings -->
<v-card class="bg-white/5 border border-white/10">
<v-card-text class="space-y-4">
@@ -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) {

View File

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

View File

@@ -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<InvitationCodeDocument> = {}
const andConditions: FilterQuery<InvitationCodeDocument>[] = []
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,
},
}
})

View File

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

View File

@@ -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<string, any>
}
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<string, number>, item: any) => {
acc[item._id || 'unknown'] = item.count
return acc
},
{} as Record<string, number>,
)
const transmissionsByRole = transmissionsByRoleRaw.reduce(
(acc: Record<string, number>, item: any) => {
acc[item._id || 'unknown'] = item.count
return acc
},
{} as Record<string, number>,
)
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),
},
}
})

View File

@@ -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<UserDocument> = {}
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,
},
}
})

View File

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

View File

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

View File

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

View File

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