Implement authentication, waitlist, and logging upgrades

This commit is contained in:
Remi
2025-09-16 17:28:34 +02:00
parent e155434b57
commit fe66a4c04c
29 changed files with 1847 additions and 64 deletions

View File

@@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { openai, routeDecision } from "../../utils/openai";
import { createReadStream } from "node:fs";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
interface PTTRequest {
audio: string; // Base64 encoded audio
@@ -120,6 +122,25 @@ export default defineEventHandler(async (event) => {
await rm(tmpAudioWav).catch(() => {});
}
try {
const user = await getUserFromEvent(event)
await TransmissionLog.create({
user: user?._id,
role: "pilot",
channel: "ptt",
direction: "incoming",
text: transcribedText,
metadata: {
moduleId: body.moduleId,
lessonId: body.lessonId,
decision,
autoDecide: shouldAutoDecide,
},
})
} catch (logError) {
console.warn("Transmission logging failed", logError)
}
const result: PTTResponse = {
success: true,
transcription: transcribedText

View File

@@ -6,6 +6,8 @@ import {join} from "node:path";
import {randomUUID} from "node:crypto";
import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
import {request} from "node:http";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
// dotenv config
import {config} from "dotenv";
@@ -132,6 +134,29 @@ export default defineEventHandler(async (event) => {
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
try {
const user = await getUserFromEvent(event)
await TransmissionLog.create({
user: user?._id,
role: "atc",
channel: "say",
direction: "outgoing",
text: raw,
normalized,
metadata: {
level,
voice,
speed,
moduleId: body?.moduleId || null,
lessonId: body?.lessonId || null,
tag: body?.tag || null,
radioQuality: radioQuality.description,
}
})
} catch (logError) {
console.warn("Transmission logging failed", logError)
}
return {
success: true,
id,

View File

@@ -0,0 +1,15 @@
import { requireUserSession } from '../../utils/auth'
import { InvitationCode } from '../../models/InvitationCode'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const codes = await InvitationCode.find({ createdBy: user._id }).sort({ createdAt: -1 }).lean()
return codes.map((code) => ({
code: code.code,
createdAt: code.createdAt,
expiresAt: code.expiresAt,
usedAt: code.usedAt,
usedBy: code.usedBy ? String(code.usedBy) : null,
}))
})

View File

@@ -0,0 +1,49 @@
import { createError } from 'h3'
import { randomBytes } from 'node:crypto'
import { requireUserSession } from '../../utils/auth'
import { InvitationCode } from '../../models/InvitationCode'
function generateCode() {
return randomBytes(4).toString('hex').toUpperCase()
}
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const accountAgeMs = Date.now() - new Date(user.createdAt).getTime()
const twoWeeksMs = 1000 * 60 * 60 * 24 * 14
if (accountAgeMs < twoWeeksMs) {
throw createError({ statusCode: 403, statusMessage: 'Einladungscodes stehen nach 14 Tagen zur Verfügung' })
}
if (user.invitationCodesIssued >= 2) {
throw createError({ statusCode: 403, statusMessage: 'Limit von zwei Einladungscodes erreicht' })
}
const activeCodes = await InvitationCode.countDocuments({ createdBy: user._id, usedBy: { $exists: false } })
if (activeCodes >= 2) {
throw createError({ statusCode: 403, statusMessage: 'Bitte vorhandene Einladungscodes zuerst nutzen' })
}
const code = generateCode()
const now = new Date()
const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30)
await InvitationCode.create({
code,
createdBy: user._id,
createdAt: now,
expiresAt,
})
user.invitationCodesIssued += 1
await user.save()
return {
success: true,
code,
expiresAt,
}
})

View File

@@ -0,0 +1,10 @@
import { clearRefreshTokenCookie, requireUserSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
user.tokenVersion += 1
await user.save()
clearRefreshTokenCookie(event)
return { success: true }
})

15
server/api/auth/me.get.ts Normal file
View File

@@ -0,0 +1,15 @@
import { requireUserSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
return {
id: String(user._id),
email: user.email,
name: user.name,
role: user.role,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
invitationCodesIssued: user.invitationCodesIssued,
}
})

View File

@@ -0,0 +1,46 @@
import { createError, readBody } from 'h3'
import { issueAuthTokens, verifyPassword } from '../../../utils/auth'
import { User } from '../../../models/User'
interface LoginBody {
email?: string
password?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<LoginBody>(event)
const email = body.email?.trim().toLowerCase()
const password = body.password?.trim()
if (!email || !password) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail und Passwort angeben' })
}
const user = await User.findOne({ email })
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
}
user.lastLoginAt = new Date()
await user.save()
const tokens = await issueAuthTokens(event, user)
return {
success: true,
accessToken: tokens.accessToken,
user: {
id: String(user._id),
email: user.email,
name: user.name,
role: user.role,
createdAt: user.createdAt,
},
}
})

View File

@@ -0,0 +1,10 @@
import { rotateRefreshToken } from '../../../utils/auth'
export default defineEventHandler(async (event) => {
const tokens = await rotateRefreshToken(event)
return {
success: true,
accessToken: tokens.accessToken,
}
})

View File

@@ -0,0 +1,78 @@
import { createError, readBody } from 'h3'
import { hashPassword, issueAuthTokens } from '../../../utils/auth'
import { User } from '../../../models/User'
import { InvitationCode } from '../../../models/InvitationCode'
import { WaitlistEntry } from '../../../models/WaitlistEntry'
interface RegisterBody {
email?: string
password?: string
name?: string
invitationCode?: string
acceptTerms?: boolean
acceptPrivacy?: boolean
}
export default defineEventHandler(async (event) => {
const body = await readBody<RegisterBody>(event)
const email = body.email?.trim().toLowerCase()
const password = body.password?.trim()
const name = body.name?.trim()
const code = body.invitationCode?.trim().toUpperCase()
if (!email || !password || !code) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
}
if (!body.acceptPrivacy || !body.acceptTerms) {
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
}
const existingUser = await User.findOne({ email })
if (existingUser) {
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
}
const invitation = await InvitationCode.findOne({ code })
if (!invitation) {
throw createError({ statusCode: 404, statusMessage: 'Einladungscode nicht gefunden' })
}
if (invitation.usedBy) {
throw createError({ statusCode: 400, statusMessage: 'Einladungscode wurde bereits verwendet' })
}
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
throw createError({ statusCode: 400, statusMessage: 'Einladungscode ist abgelaufen' })
}
const passwordHash = await hashPassword(password)
const now = new Date()
const user = await User.create({
email,
passwordHash,
name,
acceptedPrivacyAt: now,
acceptedTermsAt: now,
})
invitation.usedBy = user._id
invitation.usedAt = now
await invitation.save()
await WaitlistEntry.findOneAndUpdate({ email }, { activatedAt: now }).catch(() => undefined)
const tokens = await issueAuthTokens(event, user)
return {
success: true,
accessToken: tokens.accessToken,
user: {
id: String(user._id),
email: user.email,
name: user.name,
role: user.role,
createdAt: user.createdAt,
},
}
})

View File

@@ -0,0 +1,30 @@
import { createError } from 'h3'
import { InvitationCode } from '../../../models/InvitationCode'
export default defineEventHandler(async (event) => {
const { code } = event.context.params || {}
if (!code) {
throw createError({ statusCode: 400, statusMessage: 'Code fehlt' })
}
const normalized = String(code).trim().toUpperCase()
const invitation = await InvitationCode.findOne({ code: normalized })
if (!invitation) {
return { valid: false }
}
if (invitation.usedBy) {
return { valid: false, reason: 'used' }
}
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
return { valid: false, reason: 'expired' }
}
return {
valid: true,
code: invitation.code,
createdAt: invitation.createdAt,
expiresAt: invitation.expiresAt,
}
})

View File

@@ -0,0 +1,28 @@
import { WaitlistEntry } from '../../models/WaitlistEntry'
function maskEmail(email: string) {
const [user, domain] = email.split('@')
if (!domain) return email
const visible = user.slice(0, 2)
return `${visible}${'•'.repeat(Math.max(user.length - 2, 1))}@${domain}`
}
export default defineEventHandler(async () => {
const [count, latest] = await Promise.all([
WaitlistEntry.countDocuments(),
WaitlistEntry.find().sort({ joinedAt: -1 }).limit(8).lean(),
])
const members = latest.map((entry) => ({
name: entry.name || maskEmail(entry.email),
email: maskEmail(entry.email),
joinedAt: entry.joinedAt,
activatedAt: entry.activatedAt ?? null,
}))
return {
count,
members,
}
})

View File

@@ -0,0 +1,62 @@
import { readBody, createError } from 'h3'
import { WaitlistEntry } from '../../models/WaitlistEntry'
interface WaitlistRequestBody {
email?: string
name?: string
notes?: string
consentPrivacy?: boolean
consentTerms?: boolean
source?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<WaitlistRequestBody>(event)
const email = body.email?.trim().toLowerCase()
const name = body.name?.trim()
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
}
if (!body.consentPrivacy || !body.consentTerms) {
throw createError({ statusCode: 400, statusMessage: 'Zustimmung zu AGB und Datenschutz ist erforderlich' })
}
const now = new Date()
const existing = await WaitlistEntry.findOne({ email })
if (existing) {
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = source
existing.consentPrivacy = true
existing.consentTerms = true
await existing.save()
return {
success: true,
alreadyJoined: true,
joinedAt: existing.joinedAt,
}
}
const entry = await WaitlistEntry.create({
email,
name,
notes,
source,
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
})
return {
success: true,
alreadyJoined: false,
joinedAt: entry.joinedAt,
}
})