mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 14:16:05 +08:00
Finalize admin tools and transmission fault flow
This commit is contained in:
19
app/middleware/require-admin.ts
Normal file
19
app/middleware/require-admin.ts
Normal 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
1206
app/pages/admin/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
206
app/pages/pm.vue
206
app/pages/pm.vue
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
113
server/api/admin/invitations.get.ts
Normal file
113
server/api/admin/invitations.get.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
52
server/api/admin/invitations.post.ts
Normal file
52
server/api/admin/invitations.post.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
192
server/api/admin/overview.get.ts
Normal file
192
server/api/admin/overview.get.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
})
|
||||
81
server/api/admin/users.get.ts
Normal file
81
server/api/admin/users.get.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
52
server/api/admin/users/[id]/role.patch.ts
Normal file
52
server/api/admin/users/[id]/role.patch.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user